Files
maidong-ktv/docs/app-song-source-flow.md

68 KiB
Raw Permalink Blame History

App 歌曲源获取流程(麦动KTV Android 端)

这段话给不看代码的人

用户点一首歌,App 先看本地有没有、没有就去"歌曲源"换一条下载地址。取址有两套实现: 隐藏 WebView 里的热更新脚本 ktv_api.js(主路径)和 Kotlin 的 SongUrlResolver(原生兜底, 给 WebView 不可用的老盒子用)。两套协议完全一样:先 GET <源>/i.php 拿 token, 再 GET <源>/music/do.php 拿地址;多个源按顺序试、失败的源"沉到队尾"。 拿到的地址必须过一道可信闸门(AdVideoFilter.isTrustedSongUrl)——源站有时会返回广告视频 冒充歌曲(历史上真发生过,广告被当歌下载播放),所以地址里的 .ts 文件名必须等于点歌编号, 命中广告域名/特征一律拒收。拿到可信地址后:交给 okdownload 下载落盘 → 播放, 同时顺带把这条地址 POST 给自建服务器 /push_download(省服务器一次上游取址)。 对接自建源时多一个 GET <源>/token 拿 App 令牌(请求头 X-App-Token), 官方源不认这个接口(404/405)会被记住、之后不再尝试。全部源都失败时弹一句 「远程源已全部失效,等待新版本修复」(15 分钟节流)。

本文所有函数名、参数名、默认值、行号均从当前工作区代码逐条核对;无法确认的地方显式标注「(未在代码中确认)」。 核对基准:maidong-ktv/app/src/main/(versionName 1.6.3 / versionCode 46,见 app/build.gradle)。

2026-10-15(V1.6.3/46)更新说明 —— 点歌延迟优化 本次只重新核对了与"点歌延迟"直接相关的章节(§0 文件索引、§3.3 JS 重试语义、§4 令牌、 §5.1 heart_time、§13 新增的《点歌延迟优化》);其余章节沿用上一版(1.6.0/43 基准)的 核对结果,行号可能已漂移,引用前请以代码为准。 改动见 §13:JS 终态失败不再退避重试、上游 /i.php token 按 host 缓存(TTL=heart_time)、 App 令牌按 host 单飞、SourcePreheat 后台预热、SongFetchTrace 分阶段耗时日志、 api.php 移出取址关键路径。


0. 代码索引(本文引用到的文件)

文件 角色
java/com/local/ktv/SongApiClient.kt 四条取址路径的唯一汇合点 + 点歌推送切入点 + 主线程保护
java/com/local/ktv/SongUrlResolver.kt 原生兜底取址(复刻 JS 协议,/i.php → /music/do.php)
java/com/local/ktv/KtvJsBridge.kt 隐藏 WebView 宿主 + 裸 Socket「干净请求」通道 + 地址闸门第二处
assets/mobile/ktv_api.js JS 主取址路径(可热更新),协议第 4 处闸门
java/com/local/ktv/AppToken.kt App 令牌纯逻辑(取/缓存/不支持记忆/401 只重试一次)
java/com/local/ktv/AppTokenClient.kt 令牌的 Android 胶水(接到干净 GET 通道)
java/com/local/ktv/SourceHosts.kt 源列表 / 媒体允许清单的归一化(纯逻辑)
java/com/local/ktv/AdVideoFilter.kt 地址可信性唯一闸门 isTrustedSongUrl
java/com/local/ktv/PushDownload.kt 点歌推送纯逻辑(端点/表单/去重)
java/com/local/ktv/PushDownloadClient.kt 点歌推送 Android 胶水(后台线程 + 8s 超时)
java/com/local/ktv/KtvStore.kt 设置持久化(state.json:sourceHosts/allowedMediaHosts/pushServerUrl/pushToken/deviceIdentityJson)
java/com/local/ktv/DeviceIdentity.kt 假盒子身份(mac / sn,生成一次固定不变)
java/com/local/ktv/Whoami.kt POST hbeat.ktvsky.com/whoami 心跳(24 字段,纯逻辑)
java/com/local/ktv/MainActivity.kt 点歌入口、启动初始化、设置页、网络恢复、心跳调度
java/com/local/ktv/SongOkDownloadManager.kt 下载层(含最终兜底闸门、重试、落盘校验)
java/com/local/ktv/Song.kt Song.getDownloadUrl()(曲库条目取址入口之一)
java/com/local/ktv/Eac19WindowGuard.kt 全部源失效的用户可见提示(15 分钟节流)
java/com/local/ktv/MiliLog.kt 远程日志(GlogCenter)+ 本地 logs/ktv.log
java/com/local/ktv/SourceTokenCache.kt 上游 /i.php token 的按 host 缓存(TTL=heart_time、单飞、纯 JVM)
java/com/local/ktv/Preheat.kt / SourcePreheat.kt 取址预热纯逻辑(节流/单飞/步骤编排)+ Android 接线(JS 会话·i.php·api.php·DNS/TCP/TLS·/token)
java/com/local/ktv/SongFetchTrace.kt 取址链路的分阶段耗时日志(FETCH-TIME …,只写本地)
java/com/local/ktv/SongFileValidator.kt 内容校验(≥6MB / ≥60s / TS 包完整)——对广告无效
AndroidManifest.xml usesCleartextTraffic="true" + networkSecurityConfig(http:// 源可用)

1. 一图流

flowchart TD
    A["点歌入口<br/>MainActivity.orderSong:4930 → handleSong:4979<br/>扫码/远程 order:3573 → handleSong"] --> B["addToQueue:10382<br/>本地已就绪? → addReadySongToQueue:10403 → play:5145"]
    B --> C["下载: downloadSong:5505<br/>→ SongOkDownloadManager.download:156(io 线程)"]
    C --> D["buildDownloadUrl:248<br/>① 校验 song.downloadUrl 缓存:256<br/>② SongApiClient.getSongDownloadUrl():269"]
    D --> E{"SongApiClient<br/>resolveSongDownloadUrl:82"}
    E -->|"主线程?"| F["fetchFromDb:146(仅 DB,禁网络)"]
    E -->|"桥不可用"| G["resolveNatively:140<br/>SongUrlResolver.resolve:218"]
    E -->|"桥就绪"| H["bridge.getSongUrl:286<br/>等 15s:102 / 185s:122"]
    H -->|"JS 返回地址"| I["complete:391<br/>AdVideoFilter.resolve:210 + isTrustedDownloadUrl:368"]
    I -->|"null"| G
    H -->|"null/超时"| G
    G -->|"null"| F
    F -->|"null"| J["返回空 → 「无法生成下载地址」"]
    I -->|"url"| K
    G -->|"url(先过 isValidSongUrl:433)"| K
    F -->|"url(先过 isLikelyDemoUrl:171 + isTrustedSongUrl:153)"| K
    K["SongApiClient 4 参入口:72<br/>PushDownloadClient.offer:33(fire-and-forget)"] --> L["buildDownloadUrl 最终闸门:276<br/>AdVideoFilter.isTrustedSongUrl"]
    L -->|"不可信"| M["purgeCachedSongFile:301 + 上报「地址不可信」"]
    L -->|"可信"| N["startDownloadTask:317 → okdownload → finalizeDownload:403<br/>SongFileValidator.inspect:432 → 落盘"]
    N --> O["onDownloadComplete → playLocalFile:5191 → 播放"]
    M --> P["Eac19WindowGuard.noteAllSourcesFailed:318 → onAllFailed 弹窗"]
    J --> P

1.1 分步表(点歌 → 取址 → 校验 → 播放/下载 → 推送上报)

# 步骤 函数 文件:行
1 点歌(本地 UI) orderSong() → handleSong() MainActivity.kt:4930 / :4979
2 点歌(远程/扫码 HTTP order) order(query) → main.post { handleSong(song) } MainActivity.kt:3573
3 入队(未就绪先下载) addToQueue() → downloadSong() MainActivity.kt:10382 / :10394
4 启动下载 play() → startDownloadAndPlay() → downloadSong() MainActivity.kt:5145 / :5327 / :5505
5 下载管理器入口 SongOkDownloadManager.download() SongOkDownloadManager.kt:156
6 组装地址(先查缓存地址 + 最终闸门) buildDownloadUrl() SongOkDownloadManager.kt:248
7 四路径汇合点 SongApiClient.getSongDownloadUrl(4 参) SongApiClient.kt:72
8 取址实现(分流) resolveSongDownloadUrl() SongApiClient.kt:82
9 JS 主路径 KtvJsBridge.getSongUrl() → ktv_api.js#getSongUrl KtvJsBridge.kt:286 / ktv_api.js:427
10 原生兜底路径 SongUrlResolver.resolve() SongUrlResolver.kt:218
11 DB 回落 / 主线程 DB 兜底 fetchFromDb() SongApiClient.kt:146 / 调用点 :91
12 令牌(自建源) AppTokenClient.withToken() → AppTokenSession.withToken() AppTokenClient.kt:57 / AppToken.kt:195
13 地址可信闸门(原生/JS/DB) AdVideoFilter.isTrustedSongUrl() AdVideoFilter.kt:126(2 参)/ :131(3 参)
14 点歌推送 PushDownloadClient.offer() → submit() → post() PushDownloadClient.kt:33 / PushDownload.kt:135 / PushDownloadClient.kt:75
15 最终兜底闸门 + 上报 buildDownloadUrl() 内联判定 SongOkDownloadManager.kt:276 / :284
16 落盘 + 内容校验 startDownloadTask() → finalizeDownload() SongOkDownloadManager.kt:317 / :403(inspect 在 :432)
17 失败提示 Eac19WindowGuard.noteAllSourcesFailed() SongUrlResolver.kt:318 → Eac19WindowGuard.kt:73

2. 入口与四条取址路径

2.1 点歌入口(谁最终会去取址)

入口 位置 说明
主界面/搜索/排行/收藏/歌单点击 handleSong() MainActivity.kt:4979(orderSong() :4930 是外部组件用的公共封装) 全部收敛到 addToQueue() :10382
远程点歌页(手机扫码) MainActivity.kt:3573(override fun order(query) 里 main.post { handleSong(song) }) 走 LocalRemoteServer 的 order 回调
已点列表点击播放 playSong() :4968 → play() :5145 已就绪直接播,否则下载
曲库条目取址(另一条独立入口) Song.getDownloadUrl() Song.kt:76,内部调用 SongApiClient.getSongDownloadUrl(..., song.title) :80-82 直接命中汇合点(不经过 SongOkDownloadManager)
备用/历史路径 MainActivity.download(song) :4984 直接用 song.downloadUrl/videoUrl 下载,不取址、不过闸门(见 §11 注意事项)

启动时装配:SongApiClient.init(this) MainActivity.kt:577,内部 KtvJsBridge(context) + SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) }(SongApiClient.kt:30)+ SongUrlResolver.installPost { ... }(:32)+ AppTokenClient.install { ... }(:34)。

2.2 四条取址路径

路径 进入条件 关键函数 校验位置 失败后
① JS 桥路径 非主线程 && bridge != null && !bridge.isUnavailable(),等待 isReady() 最多 15 秒(SongApiClient.kt:102),并用 CountDownLatch 等 185 秒(:122) KtvJsBridge.getSongUrl :286 → JS KtvApi.getSongUrl ktv_api.js:427 JS isDemoUrl ktv_api.js:174;回来后再过 KtvJsBridge.complete :391(AdVideoFilter.resolve + isTrustedDownloadUrl) 返回 null/超时 → 原生兜底(:128)
② 原生兜底路径 桥为 null(SongApiClient.kt:93)、桥已判不可用(:96-98)、15s 未就绪(:109-116)、JS 取不到(:128) resolveNatively() :140 → SongUrlResolver.resolve() :218 SongUrlResolver.isValidSongUrl :433 → AdVideoFilter.isTrustedSongUrl 返回 null → DB 回落
③ DB 回落路径 前两条都没给地址(?: 链的最后一环) fetchFromDb() :146(MuseDatabase().getCloudUrl(musicno)) !isLikelyDemoUrl(url)(:171)且 AdVideoFilter.isTrustedSongUrl(url, musicno)(:153) 返回 null → 整个取址失败
④ 主线程 DB 兜底 Looper.myLooper() == Looper.getMainLooper()(:89)——主线程只允许走本地 DB fetchFromDb() :91 同 ③ 返回 null(主线程绝不发起网络取址,避免 ANR)

备用第 5 条:Song.getDownloadUrl() 在最后还有一条无校验的兜底 https://pub.cdn.cherryonline.cn/video/cloud-song/<filename>(Song.kt:86-88), 仅在前三条全部失败时返回。它不经过 AdVideoFilter。

2.3 汇合点为什么在 SongApiClient.getSongDownloadUrl()

代码里给出的理由(SongApiClient.kt:59-70、SongOkDownloadManager.kt:267-269):

  1. 四条路径都在它下面:①②③④ 的实现全部在私有 resolveSongDownloadUrl()(:82)内部串联, 因此只有这个函数能保证「只要真的拿到一条地址,就恰好推送一次」。 挂在 JS/原生各自的成功分支必然漏掉某条路径 —— 广告闸门历史上就是这么漏的。
  2. 推送是旁路:PushDownloadClient.offer()(:33)在返回之前 fire-and-forget, 不改写返回值、不影响播放(:74-78)。
  3. 主线程保护:它同时也是"主线程禁止网络 I/O"的判定点(:89-92)。
  4. 注意它是两道防线中的第一道:真正"下载地址可信"的最终判定在 SongOkDownloadManager.buildDownloadUrl(:276),因为 song.downloadUrl 缓存 (来自曲库/上次取址)也必须在用之前校验(:256-265)。

3. 源列表与选源

3.1 默认值(两处必须逐项一致)

常量 值 位置
SourceHosts.DEFAULT ["https://mdmuse.kasugano.cn", "http://zk.ktv1.top:6677"] SourceHosts.kt:31-34
SongUrlResolver.DEFAULT_HOSTS 同上(仅用于 hostOrder 初值与日志) SongUrlResolver.kt:62-65
ktv_api.js CONFIG.HOSTS 同上 ktv_api.js:29-32
ktv_api.js CONFIG.HOST "https://mdmuse.kasugano.cn" ktv_api.js:33(注意:该字段在 JS 里从未被读取,见 §11)
SourceHosts.SELF_HOST "mdmuse.kasugano.cn" SourceHosts.kt:37
SourceHosts.SELF_HOST_LAN "192.168.100.232" SourceHosts.kt:45
SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS ["md.kasugano.cn", "192.168.100.232"] SourceHosts.kt:54

约定:自建源优先、官方源兜底(SourceHosts.kt:13-20、SongUrlResolver.kt:52-61)。

3.2 设置项如何注入

设置键 默认 读 原生生效 JS 生效
sourceHosts SourceHosts.DEFAULT KtvStore.sourceHosts KtvStore.kt:87、load() :156-157(经 SourceHosts.parseList 归一化,空 → DEFAULT) MainActivity.applySourceSettings() :9417 → SongUrlResolver.applySourceHosts() SongUrlResolver.kt:141(重排 hostOrder,:142-146) KtvJsBridge.getSongUrl 内 sourceHostsForJs() :256 → 覆写 window.KtvApiConfig.HOSTS/HOST :321-322,然后 new window.KtvApi(...) :325
allowedMediaHosts SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS KtvStore.allowedMediaHosts KtvStore.kt:96、load() :158-159 AdVideoFilter.setAllowedMediaHosts() AdVideoFilter.kt:73(:9419 调用) 覆写 window.KtvApiConfig.ALLOWED_MEDIA_HOSTS :323
pushServerUrl / pushToken "" / "local" KtvStore.kt:76 / :78、load() :153-154 — —(推送用,见 §8)

归一化规则(SourceHosts.parseList :68、normalizeHost :76):去空白、去尾斜杠、 只留 http:///https:// 开头、按出现顺序去重;空列表回落 DEFAULT。 允许清单另有一套归一化 normalizeAllowedHost :102(接受整条 URL 粘贴,抽出纯主机名、小写、去端口/路径)。 设置页分隔符:换行、逗号、分号、空白(SEPARATORS :57)。

设置页入口:showSourceHostsDialog() MainActivity.kt:9427(保存 → applySourceHostsText KtvStore.kt:265 → saveState() → applySourceSettings() :9451)、showAllowedMediaHostsDialog() :9463。

⚠️ JS 侧只在下一次"重建会话"时才会拿到新列表:注入代码写在 if (!window.api) { ... } 里 (KtvJsBridge.kt:314-331)。设置页保存只调 applySourceSettings(),不会清 window.api, 因此已建好的 window.api.sourceOrder(ktv_api.js:284 在构造时快照 CONFIG.HOSTS)仍按旧顺序选源。 原生路径立刻生效。要让 JS 也立刻换源,需要 SongApiClient.resetSourceSession() (SongApiClient.kt:194 → KtvJsBridge.resetApiSession() :275 → 置空 window.api/_apiInitPromise, RESET_SESSION_JS :66-72)。目前调用点只有:断网/换网恢复 MainActivity.kt:3830、 下载重试 SongOkDownloadManager.kt:523(经 clearTokenCache)、改设备身份 MainActivity.kt:3774。

3.3 按顺序尝试 / 失败切换(两套实现同一语义)

原生(SongUrlResolver.resolve :218):

hosts = hostOrder 快照(:236)→ 按试用额度截断 take(maxHosts)(:240-244)
for host in hosts:                          // :250
    token = fetchToken(host)                // :251 → :324  GET /i.php
    失败 → demote(host) 并 continue          // :255-260
    url = fetchSongUrl(host, token)          // :261 → :340  GET /music/do.php
    成功 && isValidSongUrl → promote(host); lastHost=host; blockedUntil=0; return url   // :266-276
    失败 → demote(host)                      // :288
  • demote(host) :153:把该源移到队尾(只在不是最后一个时才动);promote(host) :163:移到队首。 hostOrder 是 Collections.synchronizedList(:68),进程内有效、不落盘(重启回默认顺序)。
  • JS 侧同名逻辑:demoteSource ktv_api.js:297、promoteSource :307、this.sourceOrder :284。
  • 一次取址最多试几个源:受 ensureConfig() 的 trial_times 限制(SongUrlResolver.kt:240)。 ensureConfig(:184)只对当前队首 POST <host>/api.php(干净 POST,KtvJsBridge.rawCleanPost :234), 缓存 30 分钟(CONFIG_TTL_MS :50),失败也写负缓存(:192,因为 zk.ktv1.top:6677 没有 api.php)。 按当前默认顺序,队首是自建源(其 api.php 返回 trial_times:0)→ maxHosts == allHosts.size → 不受限。
  • JS 侧还有一层设备重试:for deviceRound in 0..maxDeviceRetries(ktv_api.js:433), 但桥注入的是 maxDeviceRetries: 0(KtvJsBridge.kt:325)→ 只跑一轮,不会再随机换设备身份。
  • JS 重试/退避语义(V1.6.3 已改,见 §13):首次 do.php 永远立即发出;失败后按 isTerminalFailure(httpStatus, code) 分类 —— 终态失败(HTTP 4xx / 业务码 403 / 404) 不退避、不重试,直接 demoteSource 换下一个源;只有广告/错号资源、HTTP 5xx、异常才退避 RETRY_BACKOFF_MS(800ms;旧实现无条件 sleep(3000))后重试一次。 令牌被拒(401 + 令牌无效或已过期)仍是"清缓存 → 立即重试一次、不退避"。
  • ls 取值:JS 只试 ['1'](ktv_api.js:451,注释说明其余值会被风控直接拒); 原生固定 ls=1(SongUrlResolver.kt:351)。

3.4 切源对令牌缓存 / 「不支持令牌」记忆的影响

事件 令牌缓存 「不支持令牌」记忆 说明
换成另一个源 各源独立一个 key(AppTokenSession.key() AppToken.kt:209,按 host 归一化) 同上,按 host 记 互不影响
resetApiSession()(JS 侧) 不影响 Kotlin 的 AppTokenSession(它是 object 级进程内缓存);JS 侧只是把整个 window.api(连同它的 tokens,官方源 token,ktv_api.js:281/:284)置空丢弃(RESET_SESSION_JS KtvJsBridge.kt:66-72) 不受影响 断网恢复后原生/JS 会重新"按需"用旧 App 令牌,直到它过期(提前 30s)或被 401 打回
401 令牌被拒 invalidate(host) 清该源令牌 + 冷却计时 同时把 "不支持" 记忆一起清掉(安全网,AppToken.kt:169-170) 会再试一次 /token
App 重启 全部丢失 全部丢失 都是内存结构(AppToken.kt:108-114)

4. 令牌流程(App 令牌,自建源专用)

服务端契约(参考 selfhost-source/server/ktv/apptoken.py): GET/POST /token → {"ok":true,"token":"<43 字符>","expires_in":600,…},Cache-Control: no-store; 受管路径默认 DEFAULT_PATHS = ("/music/do.php", "/push_download")(apptoken.py:63); LEGACY_CHECK_PATHS = ("/i.php",)(:66);请求头名 X-App-Token(_presented() :414-432); app_token_enforce 默认 False="带了就校验"(:206、config.py:81)。

4.1 常量与触发时机

常量 值 位置
AppToken.PATH "/token" AppToken.kt:25
AppToken.HEADER "X-App-Token" :28
AppToken.DEFAULT_EXPIRES_IN_SEC 600 :31
AppToken.TIMEOUT_MS 8000 :34
AppToken.EARLY_EXPIRE_MS 30_000(提前 30 秒过期) :37
AppToken.FAILURE_BACKOFF_MS 60_000(其它失败 60 秒冷却) :45
AppToken.UNAUTHORIZED_MARKER "令牌无效或已过期" :48

触发时机:只在一个地方 —— 调 /music/do.php 之前(另外由 SourcePreheat 在后台提前取一次, 见 §13:令牌 10 分钟有效,懒到第一次点歌会白等一次往返)。 原生 SongUrlResolver.fetchSongUrl :359:AppTokenClient.withToken(host) { appToken -> callEnvelope(channel, url, appToken) }; JS ktv_api.js:413-414:httpGet(..., appTokenHeader(host)),而 appTokenHeader(:203)回调 Kotlin NativeBridge.appToken(host)(KtvJsBridge.kt:534)→ AppTokenClient.tokenFor(AppTokenClient.kt:39); 推送侧 PushDownloadClient.pushNow :61。tokenFor 内部先查缓存,未过期直接返回,不会每首歌都打 /token(AppToken.kt:129-131)。

4.2 状态机(AppTokenSession.tokenFor AppToken.kt:125-161)

情形 行为 行号
已记住"不支持" 直接返回 null(连请求都不发) :127
缓存未过期 复用 :129-131
冷却中 返回 null(不带令牌、不发请求) :133
GET <host>/token 网络异常/超时 冷却 60s,返回 null(请求照常发出,只是不带令牌) :137-140
HTTP 404 / 405 加入 unsupported → 永久(进程内)记住,返回 null :141-146
其它非 2xx(5xx/429…) 冷却 60s :147-150
2xx 但解析不出 token 冷却 60s :151-155
2xx 拿到 token 缓存,TTL = (expires_in ?? 600)*1000 - 30000,最小 1 秒 :156-158
401 / body 含"令牌无效或已过期" withToken 里 invalidate(host) → 重新 tokenFor → 再发一次请求,只重试一次 :195-201、判定 :204-207

关键细节:

  • 按 host 单飞(V1.6.3):tokenFor 的网络 I/O 只在本 host 的门闩里做 —— 同一 host 并发调用只发一次 /token,不同 host 互不阻塞(旧实现把网络 I/O 放在一把全局锁里, 一个慢源会把其它源的取令牌一起排队)。
  • isUnsupportedStatus(status) = status == 404 || status == 405(:56)。
  • isUnauthorized(status, body) = status == 401 || body.contains("令牌无效或已过期")(:63-64)—— 有的反代把 401 包成 200,所以按文案兜底。
  • 重试语义:withToken 第二次调用的结果无论对错都直接返回(:200 注释与实现)。 并且默认判定要求"本次确实带了令牌"才算令牌被拒(:206 token != null && ...)—— 没带令牌的 401 属于源站自己的事,不当令牌问题处理。
  • 失败一律降级为"不带令牌",绝不让取址失败在令牌上:AppTokenClient.withToken 的 getOrElse { ...; request(null) }(AppTokenClient.kt:58-62)、tokenFor 的 runCatching(:40-42)。
  • token 逻辑只在 Kotlin 一处(AppTokenSession),JS 只是"问一次 + 带上头 + 被拒时让原生清缓存" (ktv_api.js:199-219)。

4.3 哪些请求带令牌

请求 带 X-App-Token? 位置
GET /i.php 不带(传 null)。注释:与官方源逐字节对齐,服务端把它列在 LEGACY_CHECK_PATHS,"带了就校验",我们没带 → 老形态完全不变 SongUrlResolver.fetchToken :330 走 getJson :408(内部 callEnvelope(channel, url, null) :409);JS getToken ktv_api.js:373 用 httpGet(url) 不传 extraHeaders
GET /music/do.php 带(自适应:先试 /token,官方源 404/405 会被记住后不再尝试) SongUrlResolver.kt:359;ktv_api.js:413-414
POST <push>/push_download 带(仅当推送目标是自建源;取不到就按老形态推,服务端也认 body 里的 push_token) PushDownloadClient.kt:58-62、:86
POST /api.php(取配置) 不带 SongUrlResolver.ensureConfig :188 → rawCleanPost(url, form)(appToken 省略)
whoami 心跳 不带(Content-Type: text/plain + 空 body) MainActivity.kt:3704-3712
GET /token 自身 不带(只发 Accept) AppTokenClient.install :29 → bridge.rawCleanGet(url, null, AppToken.TIMEOUT_MS) SongApiClient.kt:34

AppTokenClient.envelope 在 404/405 时只写一条本地日志 APP-TOKEN 源不支持令牌接口(HTTP $status),之后不再尝试 source=$url(:77-78)。


5. 协议细节(逐字核对)

5.1 GET <host>/i.php(取 token)

原生(SongUrlResolver.fetchToken :324-338):

val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
val sign = md5(params + APP_KEY)
GET "$host/i.php?$params&sign=$sign"

JS(ktv_api.js:363-370)拼接顺序逐字节相同(:364-368 与上表一致)。

参数 值 / 来源
appid SongUrlResolver.APP_ID = d4eeacc6cec3434fbc8c41608a3056a0(:35)= ktv_api.js CONFIG.APP_ID :19
mac <mac>_<sn> —— DeviceIdentity.effective().mac(SongUrlResolver.kt:107)+ "_" + sn。JS 用 self.mac + '_' + self.sn(ktv_api.js:365)
sn DeviceIdentity.effective().sn(SongUrlResolver.kt:105);JS 由桥注入 sn: DEVICE_SN(KtvJsBridge.kt:325)
time 秒级 Unix 时间戳:原生 System.currentTimeMillis()/1000(:325);JS Math.floor(Date.now()/1000)(:363)
ver SongUrlResolver.VER = "2.0"(:38);JS 由桥覆写 KtvApiConfig.VER = '2.0'(KtvJsBridge.kt:318)
vn SongUrlResolver.VN = "4.1.3.03281430"(:39);JS 由桥覆写 KtvApiConfig.VN = '4.1.3.03281430'(:319);与 Whoami.VN(Whoami.kt:40)同值
sign md5(params + APP_KEY);APP_KEY = 024210cba40d4385a93e6c2d3249bfb5(:36)
请求头 只有 Accept: */*(原生 rawCleanGet KtvJsBridge.kt:220;JS httpGet ktv_api.js:234)

返回(App 侧实际读取的字段):

字段 App 用法
code != 200 → 该源取 token 失败(SongUrlResolver.kt:333、ktv_api.js:376);== 403 时原生置 sawForbidden(:332)
token json.optString("token")(:337)/ data.token(ktv_api.js:377)
msg 只用于本地日志(SongUrlResolver.kt:334)

服务端还会返回 retry / ordertype / end_date / heart_time 等字段 (selfhost-source/server/ktv/service.py:70-73)。 ⚠️ V1.6.3 起 heart_time 被消费:原生兜底路径把它当作 /i.php token 的缓存时长 (秒;实测 5400 = 90 分钟,见 SourceTokenCache)。retry / ordertype / end_date 仍无消费方(语义未在 App 代码中确认)。

5.2 GET <host>/music/do.php(取播放地址)

原生(SongUrlResolver.fetchSongUrl :340-378):

val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" +
             "&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token"
val sign = md5(params + SDK_KEY)
GET "$host/music/do.php?$params&sign=$sign"    // + 可选请求头 X-App-Token

JS(ktv_api.js:401-413)拼接顺序逐字节相同。

参数 值 / 来源
appid 同 §5.1
device <mac>_<sn>(注意与 i.php 的 mac 参数同名不同键)
ish265 "1" / "0";原生入参默认 h265 = false(SongUrlResolver.resolve :218),调用链传 false(SongOkDownloadManager.kt:269、MainActivity.kt:5239)
ls 恒为 "1"(原生 :351;JS lsValues = ['1'] :451)
musicno 点歌编号(SongOkDownloadManager.buildDownloadUrl 的 musicNo,:254)
resolution 调用链固定 "720"(SongOkDownloadManager.kt:269、MainActivity.kt:5239);resolve 默认参数也是 "720"(:218)
sn 同 §5.1
time 秒级时间戳
token /i.php 返回的那个 token(源站 token,不是 App 令牌);原样拼接、不做 URL 编码(注释强调"原始 token 中的 ="必须保留,:350、ktv_api.js:400)
sign md5(params + SDK_KEY);SDK_KEY = 19042303a8374f67ae3fe1e25c97936f(:37)
额外请求头 X-App-Token(自建源,可有可无)

返回结构与字段:

字段 类型 App 用法
code int 200 = 成功;其它 = 该源失败。403 时原生置 sawForbidden(:373)
msg string 只进本地日志(:374);服务端成功时是 "SUCCESS"(service.py:89)
data string 媒体地址,takeIf { it.isNotEmpty() }(:377);JS 取 data.data(ktv_api.js:490)
limit int App 侧从不读取(全仓 grep limit 无命中)。服务端在成功时返回 "limit": 2000(service.py:89),语义未在 App 代码中确认

JS 侧还有额外约束:返回地址必须匹配 /^https?:\/\//i(ktv_api.js:492), 且 HTTP 非 200 时整体视为失败(_fetchSongUrl :415-417 返回 {code:null, data:'', httpStatus, body})。

5.3 mac / device / sn 的来源(假盒子身份)

  • 统一来自 DeviceIdentity(Object 单例),三个请求(whoami / i.php / do.php)同源:
    • KtvJsBridge.DEVICE_MAC :55 / DEVICE_SN :56 → 注入 JS(:325、:329-330)
    • SongUrlResolver.sn :105 / mac :107
    • MainActivity.sendDeviceHeartbeat 用 DeviceIdentity.effective().toFacts(pkn = store.whoamiPkn) (:3695-3697,toFacts 见 DeviceIdentity.kt:181-196)
  • 生成规则(DeviceIdentity.generate :329-357):随机挑一套真盒子模板(:84-120: Amlogic S905X/S905X3/S905X4、Rockchip RK3328、Realtek RTD1295)→ mac = OUI + randomHex(6)(:331、newMac :422,OUI 是真实盒子厂商前缀)→ sn = randomHex(12)(:337)→ 落盘进 KtvStore.deviceIdentityJson(install :278-290、sink :281-286)。
  • 生成一次后固定不变;只有设置页「重新生成设备身份」会换(MainActivity.kt:3744 / :3768)。 换身份会同时 SongApiClient.clearTokenCache()(:3774)并补一次 whoami(:3775)。
  • validate() :364-416 校验自洽(12 位小写 hex、单播位、OUI 在白名单、_api 与 d_release 对应、 fingerprint 形态…);不合法就重新生成(install :280)。
  • ⚠️ 这是构造值,不是网卡真实 MAC。真实网卡 MAC 由 DeviceMac 解析,只用于本地日志 (MainActivity.kt:3737、日志行 :3776-3779)。

5.4 签名算法

sign = md5( 参数字符串 + 密钥 )     // 直接字面拼接,无分隔符
  • /i.php:密钥 APP_KEY = 024210cba40d4385a93e6c2d3249bfb5(SongUrlResolver.kt:36,ktv_api.js:20), 参数串不含 sign、顺序见 §5.1。
  • /music/do.php:密钥 SDK_KEY = 19042303a8374f67ae3fe1e25c97936f(:37,ktv_api.js:21)。
  • md5 实现:原生 MessageDigest.getInstance("MD5") + "%02x" 小写十六进制(SongUrlResolver.kt:436-438); JS 手写 MD5(ktv_api.js:51-107),同样输出小写 hex。
  • ⚠️ ktv_api.js 里的 CONFIG.VER/VN(4 / 4.100.5.11121504,:22-23)不是实际使用的值 —— 桥在构造 KtvApi 之前把它们覆写成 2.0 / 4.1.3.03281430(KtvJsBridge.kt:318-319)。

5.5 传输通道(为什么必须"干净请求")

两套实现都走同一段裸 Socket(KtvJsBridge.NativeBridge.rawRequest :628-708):

  • 请求行只发 Host + 调用方指定的头(i.php/do.php 只有 Accept: */*), 不补 UA / Connection / Accept-Encoding(:627 注释)。
  • HTTPS 用 TlsCompat.sslSocketFactory() + createSocket(plain, host, port, true)(:648-651,先连接再包 SNI)。
  • 连接超时显式设置 connect(InetSocketAddress(host, port), connectTimeout)(:646、:656-658), soTimeout = timeoutMs.coerceIn(1000, 30000)(:660)。
  • 裸 Socket 失败(status 0)才回退 HttpURLConnection(request :580-589;回退实现 httpRequest :593-625, 中性头:Go-http-client/1.1 / Accept-Encoding: identity / Connection: close); 两个都失败才 MiliLog.localError("NET-ERR ...")(:587)。 裸 Socket 异常打 MiliLog.warn("RAW-ERR ...")(:700-703,这条进远程日志)。
  • ⚠️ 这条通道不受 network_security_config 与 HttpsURLConnection 默认工厂影响(注释 :637-639)。

6. 地址可信性判定(唯一闸门 AdVideoFilter.isTrustedSongUrl)

6.1 规则清单(按判定顺序)

AdVideoFilter.isTrustedSongUrl(url, musicno, allowedHosts) :131-151:

序 判定 不通过时 行号
1 url 非空 拒 :132
2 必须以 http:// 或 https:// 开头 拒 :133
3 isAdVideo(url) == false(黑名单,优先级最高,命中允许清单也要拒) 拒 :134
4 hostOf(url) 能取到 host 拒 :135
5 裸 IP(IP_HOST = ^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$,:200)且不在允许清单 拒 :146
6 pub-*.r2.dev + path 含 /ss/(R2_HOST :203,不受允许清单影响) 拒 :147
7 path 里的 .ts 文件名主体必须等于 musicno(musicno 为空时跳过该规则) 拒 :148-149

黑名单明细:

  • AD_MARKERS(host + path 上做小写 contains,:26-33):"/ad_files/"、"my_ad_video"、"wb66"、"demo"。
  • AD_HOSTS(精确或子域匹配,:43-46):"gz.ac16.vip"、"ac16.vip"。
  • isAdVideo(:97-105):host 取不到时退化为整串匹配(:101,宁可拦下也不放行广告)。

允许清单:

  • allowedMediaHosts(@Volatile var,:69,默认 md.kasugano.cn + 192.168.100.232; 设置项经 setAllowedMediaHosts() :73 写入)。
  • isAllowedMediaHost(host)(:84-88):精确匹配或子域匹配(value.endsWith(".$it"))。
  • 作用范围:只豁免第 5 条(裸 IP)。第 3、6、7 条不受影响。

.ts 文件名解析:tsBasename(path)(:193)用 TS_BASENAME = Regex("/([^/]+)\\.ts(?:/|$)", IGNORE_CASE)(:197)。 path 由 pathOf()(:178)取得,已剥离 query 与 fragment;host 由 hostOf()(:160)取得, java.net.URI 失败时手工正则兜底(:163-168)。hostOf/pathOf 的剥离是硬性要求: 真源地址形如 …/480p/7200088.ts?token=…&t=…(:120-123 注释),拿整串判定会漏放或误杀。

6.2 「四处闸门同口径」的具体位置

# 路径 闸门代码 位置
1 原生 SongUrlResolver.isValidSongUrl() → AdVideoFilter.isTrustedSongUrl(url, musicno) SongUrlResolver.kt:433-434(调用点 :266)
2 KtvJsBridge(JS 回来之后) complete() → AdVideoFilter.resolve(url)(= isAdVideo,广告返回 null)→ 再 isTrustedDownloadUrl(url) KtvJsBridge.kt:391-394;resolve AdVideoFilter.kt:210;isTrustedDownloadUrl KtvJsBridge.kt:368-389
3 SongApiClient(DB 回落) fetchFromDb() → !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno) SongApiClient.kt:153;isLikelyDemoUrl :171-179
4 JS isDemoUrl(url, musicno) ktv_api.js:174-197(调用点 :472、:492、:500)
5 (最终兜底,非"四路径"之一) SongOkDownloadManager.buildDownloadUrl:缓存地址 :257、新取地址 :276 SongOkDownloadManager.kt:248-298

第 2 处多一层 CDN 域名后缀白名单,这是其它三处没有的: trustedHostSuffixes = ["ktvsky.com","origjoy.com","cherryonline.cn","ac16.vip","ktvdaren.com","j-make.cn"] (KtvJsBridge.kt:359-362);host 既不在后缀白名单、又不在允许清单 → 判不可信(:386-388)。 自建源媒体域名(md.kasugano.cn / 192.168.100.232)正是靠允许清单通过这一层(:381)。

服务端同口径实现(对端参考):selfhost-source/server/ktv/resolver.py:121 is_trusted_url(url, tid)。

6.3 优先级总结

非 http(s)  → 拒(最高)
广告黑名单(wb66 / demo / ac16.vip / ad_files / my_ad_video  @ host+path)→ 拒(命中允许清单也拒)
R2 测试桶(pub-*.r2.dev + path 含 /ss/)→ 拒(命中允许清单也拒)
裸 IP → 拒,除非 host 命中 allowedMediaHosts(精确或子域)
.ts 文件名 != musicno → 拒(musicno 为空则跳过)
其余 → 放行

判定失败的处理:SongOkDownloadManager.buildDownloadUrl 的不可信分支(:282-289)会 purgeCachedSongFile(song, "untrusted-resolved-url")(:301-315,删除已落盘的歌曲文件 + SongFileValidator.forget)并上报 MiliLog.error("点歌编号=… 状态=失败 详情=地址不可信(广告或文件名不符) url=…")。 缓存地址不可信时也删文件(:261-264,理由见 :258-260:广告能通过内容校验,一旦落盘会被 isDownloaded() 永久复用)。


7. 失败 / 回落矩阵

约定:官方源 = http://zk.ktv1.top:6677;自建源 = https://mdmuse.kasugano.cn(默认排第一)。 "某源失败" = demote(host) 后换下一个源;"回落" = 上一级路径返回 null 后交给下一级。

情形 官方源表现 自建源表现 App 动作
code:200 + 地址可信(.ts 名 == 编号、域名/CDN 合法) msg=SUCCESS、真源 CDN(txog.ktvsky.com / download.origjoy.com) msg=SUCCESS、data 指向 md.kasugano.cn:4433/media/<编号>.ts(或内网裸 IP) promoteSource/promote 该源 → lastHost/报告源 记录 → 原生解除冷却(blockedUntil=0 SongUrlResolver.kt:269) → Eac19WindowGuard.noteUrlOk()(SongApiClient.kt:131) → 推送一次 → 最终闸门通过 → 落盘下载
业务码 403(HTTP 200 + {"code":403,"msg":"请求被拒绝:检测到异常行为"}) 历史形态 服务端默认不会(用 404 表达未就绪) JS:needRetry → sleep(3000) → 同 token 重试一次(ktv_api.js:482-486)→ 仍 403 → demote。原生:code==403 → sawForbidden=true(SongUrlResolver.kt:373)→ 判失败 → demote。全部源失败且 sawForbidden → 3 分钟冷却(BLOCKED_COOLDOWN_MS :47)+ MiliLog.localError("SOURCE-BLOCKED 源站返回 HTTP 403…") + Eac19WindowGuard.noteRiskBlocked()(只写日志、无弹窗)+ noteAllSourcesFailed()
HTTP 403(状态码本身) 实测主源 /i.php 会直接 403 不会 KtvJsBridge.request:MiliLog.localError("HTTP-403 …"),且 URL 含 /i.php 或 /music/do.php → Eac19WindowGuard.noteRiskBlocked()(KtvJsBridge.kt:568-577)→ raw 返回 JS → 该源判失败 → demote。原生 callEnvelope:status==403 → sawForbidden(SongUrlResolver.kt:398)
业务码 404({"code":404,"msg":"not in library"} / not ready: pending) — 服务端约定用它表达"这首还没下载完成"(service.py:28、:90-96) 一律当"该源没地址":code!=200 → 同上重试一次 → demote → 换源;不会触发 sawForbidden,也不弹"换 IP"
广告/错号地址(code:200 但 isDemoUrl 命中:wb66 / 广告域名 / .ts 名 ≠ 编号) 出口 IP 授权窗口失效时的新形态:对每首歌都返回同一段广告 不应发生 JS:needRetry=true(ktv_api.js:472-475)→ 3 秒退避重试一次 → 仍广告 → demote。全部源都只给广告(原生统计 adOnlyCount>0 && otherFailCount==0,SongUrlResolver.kt:296-304)→ 3 分钟冷却 + MiliLog.error("SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源(该出口 IP 授权窗口失效)") + noteRiskBlocked() + noteAllSourcesFailed()
地址不可信但拿到了 URL(最终闸门拒收) 同广告行 — SongOkDownloadManager:MiliLog.error("… 状态=失败 详情=地址不可信(广告或文件名不符) url=…") + purgeCachedSongFile → 下载返回空 → onDownloadFailed("无法生成下载地址")
超时 — — 单次请求:JS 默认 8000ms(ktv_api.js:231);原生 TIMEOUT_MS = 8000(SongUrlResolver.kt:40);裸 Socket 连接/读取各 coerceIn(1000, 30000)(KtvJsBridge.kt:635、:660)。JS 整体受桥注入的 180 秒兜底(JS_REQUEST_TIMEOUT_MS KtvJsBridge.kt:48,setTimeout → finish('') :312);SongApiClient 等 185 秒(:122)。超时 → 该源失败 → demote → 换源;全部失败 → 回落原生 → 回落 DB → 「无法生成下载地址」
HTTP 5xx — — MiliLog.localError("HTTP-5xx …")(KtvJsBridge.kt:568-570,不触发风控提示)→ JS 判失败 → 重试一次 → demote;原生 callEnvelope 记 localWarn → null
令牌 401(带 X-App-Token 被拒) 官方源没有 /token(404/405)→ 从不带该头 /token 正常;令牌过期 → {"ok":false,"error":"令牌无效或已过期"} + 401 原生:AppTokenClient.withToken 判定 → invalidate → 重取 → 再发一次,只重试一次(AppToken.kt:195-201)。JS:tokenRejected → invalidateAppToken(host) → 重取头 → 不 sleep 立即重试一次(ktv_api.js:469-486)。第二次仍 401 → 该源判失败 → demote
/token 404/405 命中(官方源) 不会 记住"该源不支持令牌"(进程内永久)→ 之后不再尝试、也不带该头(AppToken.kt:141-146);请求照常发(服务端 app_token_enforce=false)
/token 其它失败(网络/5xx/解析不出) — 临时抖动 60 秒冷却(AppToken.FAILURE_BACKOFF_MS)→ 期间不带令牌发请求;冷却后自动重试
网络不可达(裸 Socket 与 HttpURLConnection 都失败) — — MiliLog.localError("NET-ERR …") → 该源失败 → … → 全部失败 → 「无法生成下载地址」;下载层把它记进 networkFailed(SongOkDownloadManager.kt:387-390、isNetworkFailure :234-246),网络恢复后 retryNetworkFailed()(:220,最多 8 首)
全部源彻底失败 — — SongUrlResolver.resolve 末尾无条件调 Eac19WindowGuard.noteAllSourcesFailed()(:318)→ onAllFailed 弹一句**「远程源已全部失效,等待新版本修复」**,15 分钟节流(Eac19WindowGuard.kt:42、:73-84)。注意:刚进入 blockedUntil 冷却时的下一次取址会在 :227-232 直接返回 null,不会再弹(弹窗在进入冷却那一次已发)
下载中断/卡死/文件异常 — — 重试链:MAX_RETRY_COUNT = 3(:23)、STALL_TIMEOUT_MS = 25_000(:24,看门狗 :489-507)、退避 attempt*750ms 上限 2250ms(:525)。每次重试先 song.downloadUrl = null + SongApiClient.clearTokenCache()(:522-523,会重置 JS 会话、回到默认源顺序)再重新取址。落盘校验:MIN_VALID_FILE_SIZE(:414)、TsDecryptor.isEncrypted(:420)、SongFileValidator.inspect(:432,失败提示"服务器返回试听片段(N 秒),未保存也未加入已点" :442-445)
用户可见提示 — — 只有一句(Eac19WindowGuard.onAllFailed);风控 403 只写日志(原「歌曲源临时受限…换 IP」弹窗已按用户要求删除,Eac19WindowGuard.kt:36-37)

8. 点歌推送(App → 自建服务器 /push_download)

8.1 时机与目标

项 值 / 位置
时机 拿到下载地址之后,在汇合点里:SongApiClient.getSongDownloadUrl 4 参版 :72-79,条件 !url.isNullOrEmpty() && musicno.isNotBlank();musicno 会 trim()(:75)
调用 PushDownloadClient.offer(musicno, url, songName) PushDownloadClient.kt:33(fire-and-forget,异常只 Log.w :76)
目标(默认规则) PushDownload.effectiveServerUrl(configured, sourceHosts) PushDownload.kt:58-65:① 显式非空且不是 DISABLED_VALUES → 原样用;② 显式 off/none/no/0/false/关闭/禁用(:42)→ 关闭;③ 留空 → 若 sourceHosts 第一项是自建源(SourceHosts.isSelfHost :118,即主机名等于 mdmuse.kasugano.cn 或 192.168.100.232)就用它,否则关闭
端点 endpoint(serverUrl) PushDownload.kt:73-78 = <去掉尾部 />/push_download;空串/非 http(s) → 返回 null → 连线程都不起(PushDownloadClient.kt:52)
请求方式 POST,Content-Type: application/x-www-form-urlencoded; charset=utf-8,User-Agent: MaidongKTV/1.2,连接+读取超时各 8000ms(PushDownloadClient.kt:75-104,PushDownload.TIMEOUT_MS :31)
线程 独立守护线程 "push-download"(PushDownloadClient.kt:37-43),每次现读设置(KtvStore().load() :48-49,保证设置页改完立刻生效)
鉴权 body 里的 token(store.pushToken,默认 "local",KtvStore.kt:78/:309)+ 自建目标额外带 X-App-Token(:86,经 AppTokenClient.withToken(host) :61,host = SourceHosts.hostOf(serverUrl) :53)

8.2 body 字段(PushDownload.buildForm :87-93)

字段 值
tid 点歌编号(musicno.trim().toLongOrNull(),必须 > 0,否则直接放弃,PushDownloadClient.kt:34)
url 刚拿到的媒体地址,整体 URLEncoder.encode(:90;注释强调不编码会被服务端表单解析拆成多个字段,:83-85)
name 歌名(可选,仅用于服务器日志核对,不入库)
token pushToken(空串时回落 "local",:88)

8.3 去重与失败处理

  • 5 分钟内存去重:DEDUPE_MS = 5 * 60_000(:28);shouldSkip(tid, now) :97-100; key 是 tid.toString(),表上限 MAX_REMEMBERED = 512(:34),超限时按 now - DEDUPE_MS*4 清旧(:107-113)。
  • 去重检查在两处:offer 里先廉价短路(PushDownloadClient.kt:36,不起线程), submit 里再判一次(PushDownload.kt:139)。
  • markPushed 在 sender.send 之前执行(:140-141)→ 同一编号 5 分钟内失败也不重试。
  • 失败只写本地日志:MiliLog.localWarn("PUSH-DL 推送失败 端点=… HTTP …") :95 / MiliLog.localWarn("PUSH-DL 推送失败 端点=… <异常>") :99;成功也只写本地 MiliLog.local("PUSH-DL 已推送 编号=… 服务器=…") :67。绝不弹提示、不阻塞、不改返回值。
  • submit 的 sender 抛异常被吞(PushDownload.kt:141-145 返回 false)。
  • 与令牌的关系:令牌拿不到就"不带 X-App-Token 按老形态推"(服务端也接受 body 的 push_token, pushdl.py:213-217);令牌被拒(401)时 AppTokenClient.withToken 会清缓存重取、只重试一次。
  • 服务端行为(对端参考 pushdl.py:204-264):① 鉴权(X-App-Token 或 push_token); ② 同 IP 每分钟限流(超限 429 + Retry-After);③ tid 必须正整数;④ url 必须 http(s) 且过 is_trusted_url(url, tid) 闸门;⑤ 去重三态 already_done|already_queued|recently_pushed; ⑥ 接受则 push_enqueue(tid, url),name 只写事件。

9. JS 与原生一致性

9.1 KTV_BRIDGE_API 版本号的作用

位置 值/用法
ktv_api.js:15 注释标记 // KTV_BRIDGE_API: 14 (Android 缓存校验标记,请勿删除)
ktv_api.js:535 global.KtvBridgeApiVersion = 14(供 JS 侧/调试读取)
KtvJsBridge.kt:403 isValidScript() 要求缓存文件同时包含 "KtvApi" 且包含 "KTV_BRIDGE_API: 14"(长度还必须在 1001..500000 字节)
KtvJsBridge.kt:427-430 updateCache() 下载远端脚本后 require(script.contains("KTV_BRIDGE_API: 14")),否则抛错、删临时文件、保留旧脚本

作用:版本闸门。远端热更新脚本若不兼容(标记不等于 14)→ 拒绝落地, 设备继续用已有缓存/内置脚本。改 ktv_api.js 的取址协议时必须同步提升这里的数字(两处)。

9.2 KtvApiConfig 注入了哪些字段

只在 if (!window.api) 分支里注入一次(KtvJsBridge.kt:314-331,取址时执行):

字段 注入值 位置
VER '2.0' :318
VN '4.1.3.03281430' :319
HOSTS sourceHostsForJs()(设置项 sourceHosts,空则 SourceHosts.DEFAULT) :321(读取 :256-260)
HOST hosts.firstOrNull() :322(JS 侧从未读取该字段,见 §11)
ALLOWED_MEDIA_HOSTS allowedMediaHostsForJs()(设置项,空则默认清单) :323(读取 :263-267)

构造:new window.KtvApi({mac: DEVICE_MAC, sn: DEVICE_SN, maxDeviceRetries: 0, debug: true})(:325); 并包一层 regenerateDevice,防止 JS 自己换设备身份(:326-331)。 未注入 APP_ID / APP_KEY / SDK_KEY —— 它们沿用 ktv_api.js 自带值(当前与 Kotlin 侧一致); 也未注入 MWS / RSA_PUBKEY。

debug: true → KtvApi._log() 走 console.log(ktv_api.js:294), 被 wrapHtml 注入的 console 桥转发到 android.log(KtvJsBridge.kt:478-484)→ NativeBridge.log → MiliLog.local("JS " + message)(:493-498,只写本地)。

9.3 脚本来源与热更新回退链

  1. 优先用缓存 filesDir/ktv_api_cache.js(CACHE_FILE :43),仅当 isValidScript 通过才用(:110-112)。
  2. 否则用打包内置 assets/mobile/ktv_api.js(readAssetScript :396)。
  3. updateCache(:408-451)在后台线程拉远端 https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js (REMOTE_JS_URL :45-46,UA: KTV-Updater/2.0,连接 10s / 读取 15s,≤2MB), 校验通过后原子替换;若当前空闲(ready && callbacks.isEmpty() && !destroyed)立即热重载(:438-443), 否则下次启动生效。
  4. 缓存脚本加载后没有导出 window.KtvApi → 回退内置 asset 一次(:173-177,assetRetryAttempted 防重入)。
  5. 内置 asset 也没导出 → markUnavailable("KtvApi 未导出")(:182)→ 之后取址直接走原生通道, 不再每次白等 15 秒(SongApiClient.kt:96-98)。
  6. 看门狗:BRIDGE_INIT_TIMEOUT_MS = 20_000(:50)内 onPageFinished 未回调或未导出 → markUnavailable("初始化超时:onPageFinished 未回调或 KtvApi 未导出")(:116-121)。
  7. WebView 构造抛异常 → markUnavailable("WebView 初始化失败: …")(:122-126)。
  8. 桥后来就绪(热重载成功等)会自动清除 unavailable 并上报一条 JS-BRIDGE 已恢复可用(<原因> 已解除)(:164-169)。

其它一致性约定:

  • 两套实现读同一份设置(KtvStore.sourceHosts / allowedMediaHosts),默认值逐项一致。
  • 两套实现走同一条裸 Socket 通道(原生用 KtvJsBridge.rawCleanGet(rawCleanPost))。
  • 两套实现的风控表现一致:都只发 Host + Accept(/i.php、/music/do.php)。
  • 原生是兜底、JS 是主路径:源站配置变更时 JS 能热更新,而 SongUrlResolver 里的 APP_ID/APP_KEY/SDK_KEY/VER/VN 是编译进 APK 的镜像,必须发版才能改(SongUrlResolver.kt:26-28)。
  • wrapHtml(:454-489)额外注入诊断脚本:上报 WebView UA 与 async/await 支持(:472-477)、 window.onerror(:466-469)、console.log 桥(:478-484)。

10. 可核对清单(How to verify,8~12 条)

# 验证项 怎么做 期望
1 默认源列表 看 App「设置 → 歌曲源」文本框 / <存储根>/state.json 的 sourceHosts http://zk.ktv1.top:6677 在前(官方源优先)、https://mdmuse.kasugano.cn 在后(兜底)
2 源列表立刻换源(原生) 设置里只留一个错误源(如 http://127.0.0.1:1)保存 → 点一首未下载的歌 日志出现 SOURCE-HOSTS 歌曲源已更新(1 个): …(SongUrlResolver.kt:147,本地日志)+ NATIVE-SONG 全部源失败 …
3 JS 路径是否真的按新源走 同上改设置后不重启,看本地日志是否仍出现旧源 Try source (1/N): … ⚠️ 仍会用旧顺序(需 resetApiSession 或重启才生效,见 §3.2 注)
4 令牌接口(自建源) 服务器上 curl -s http://<自建源>/token/status(或 GET /api/client_ip) live_tokens/pending_log 有签发计数;App 本地日志有 APP-TOKEN … 行
5 官方源"不支持令牌"记忆 把 sourceHosts 设成官方源打头,点一首歌,再看本地日志 一条 APP-TOKEN 源不支持令牌接口(HTTP 404/405),之后不再尝试 source=…,且后续点歌不再请求 /token
6 令牌 401 只重试一次 单测:app/src/test/java/com/local/ktv/AppTokenTest.java 的 unauthorizedClearsCacheRefetchesAndRetriesExactlyOnce / unauthorizedRetryHappensOnlyOnceEvenIfStillRejected 通过(fetchCount 断言只多一次)
7 地址闸门四处口径 跑单测 AdVideoFilterTest、MediaHostAllowlistTest、SourceHostsTest 全绿(含带 query/fragment 真源放行、广告仍拒、黑名单压过允许清单)
8 广告地址被拦后的行为 本地日志搜 SONG-AD 丢弃不可信缓存地址 / SONG-AD 清除可疑歌曲缓存 / 远程日志搜 状态=失败 详情=地址不可信 地址被清、已落盘文件被删(purgeCachedSongFile),下次点歌重新取址
9 全部源失效提示 断网或让出口 IP 进入受限态后点歌 远程日志一条 NATIVE-SONG 全部源失败 编号=… 详情=token-fail|no-url|demo-or-mismatch 用时=…ms(SongUrlResolver.kt:307)+ 界面弹「远程源已全部失效,等待新版本修复」(15 分钟节流)
10 断网 / 恢复表现 断开网络 → 播放页右上角角标「网络未连接」;恢复后远程日志 NET-DOWN 设备网络已断开 → NET-UP 设备网络已恢复, 重建歌曲源会话 → NET-REFRESH reason=net-up retried=N(MainActivity.kt:3799/:3813/:3835)
11 点歌推送(自建目标) 服务器 curl -s <自建源>/push_download/status;App 本地日志搜 PUSH-DL accepted/duplicate 计数增长;App 本地一条 PUSH-DL 已推送 编号=… 服务器=…。填 off 后应无任何 PUSH-DL 行
12 点歌推送去重 5 分钟内反复点同一首(或反复取址同一编号) 只推一次(PushDownloadTest.dedupeSameTidWithinFiveMinutes;shouldSkip :97)
13 热更新脚本不兼容时回退 把 Gitea 上 ktv_api.js 的 KTV_BRIDGE_API: 16 改成别的值后重启 App 远端脚本被拒(logcat JS update failed: Invalid or incompatible JS file),仍按缓存/内置脚本运行
14 WebView 不可用时的原生兜底 真机日志搜 JS-BRIDGE / NATIVE-SONG 有 JS-BRIDGE 判定本机 WebView 不可用(…),后续点歌直接走原生取址通道,之后 NATIVE-SONG 取址成功 编号=… 源=… urlHost=…(远程日志)
15 本地日志文件 <存储根>/logs/ktv.log(启动行 APP-INIT … logFile=<路径>) 逐源/逐次细节(JS …、Try source、APP-TOKEN)都在这里;远程只有"结论"一条

11. 与预期不一致 / 值得注意的点(阅读代码时发现)

  1. ktv_api.js 的 CONFIG.VN / CONFIG.VER 是"死值":文件里写的是 '4.100.5.11121504' / '4' (:22-23),实际取址用的是桥覆写后的 '4.1.3.03281430' / '2.0'(KtvJsBridge.kt:318-319)。 直接读 JS 文件会得出错误的协议版本结论。
  2. CONFIG.HOST 被注入但从未被读取:KtvJsBridge.kt:322 写了它,ktv_api.js 全文只用到 CONFIG.HOSTS(getToken 的默认值用 CONFIG.HOSTS[0],:358)。属于无害的冗余字段。
  3. 设置页改"歌曲源"对 JS 主路径不立即生效:注入代码在 if (!window.api) 里, 设置页保存只调 applySourceSettings()(MainActivity.kt:9451),不重置会话。 已构造的 window.api.sourceOrder(构造时快照)仍生效,直到重启/断网恢复/下载重试触发 resetApiSession()。 原生兜底路径则立刻生效 —— 两条路径在同一时刻可能用不同的源顺序。
  4. 业务码 403(HTTP 200)在 JS 路径不触发风控提示:KtvJsBridge.request 只在 status == 403 时调 Eac19WindowGuard.noteRiskBlocked()(:575-577);JS 侧看到 {"code":403}(HTTP 200) 时只是"该源失败 + 退避重试一次"。只有原生 SongUrlResolver 会在业务码 403 上置 sawForbidden (:332、:373)。所以"业务码 403 风控"这个信号在 JS 路径上是丢失的(远程日志里只能看到 点歌失败 … 详情=do.php code=403 …)。
  5. 401 重试确实只有一次,两套实现口径一致但细节不同:原生 AppTokenSession.withToken 第二次结果 直接返回(AppToken.kt:200);JS 侧 tokenRejected 分支跳过 3 秒退避立即重试(ktv_api.js:478-481), 而风控/广告场景会 sleep(3000) 再重试(:483-485)。
  6. AppTokenSession 的"永久不支持"只是进程内记忆:unsupported 是普通 HashSet (AppToken.kt:110),不落盘,App 重启后会重新探测一次 /token。文档/注释里的"永久"= 本次进程生命周期。
  7. KtvJsBridge.isTrustedDownloadUrl 多一层 CDN 域名后缀白名单,且白名单里含 ac16.vip (KtvJsBridge.kt:360)—— 也就是广告域名。它之所以没造成事故,仅仅因为 complete() 先调 AdVideoFilter.resolve()(isAdVideo → AD_HOSTS 含 ac16.vip)把广告拦掉了(:392-394)。 这是一个依赖调用顺序的安全网:如果哪天有人把 resolve() 挪到 isTrustedDownloadUrl 之后, ac16.vip 的广告就会被这条白名单放行。
  8. SongOkDownloadManager 用 song.filename 推编号;缺 filename 时退化成 song.id (:253-254)。song.id 在曲库条目里可能是数据库主键,这个值会一路传给 SongApiClient(闸门的 musicno)与点歌推送的 tid:非数字时推送直接放弃, 数字但与 .ts 文件名不符时会把正常地址误杀成"文件名不符"。代码里只用它做"下载层最终闸门"的 参数(:276 传的是 musicNoFromFile,可能为 null → 跳过文件名规则),但 getSongDownloadUrl(musicNo.orEmpty(), …) (:269)传的是带回退的 musicNo → DB 回落路径的闸门仍会用这个可能有歧义的值。
  9. streamDownloadAndPlay(MainActivity.kt:5232)是死代码:全仓 grep 只有定义与一处注释, 没有任何调用点;实际"点歌即下即播"走的是 startDownloadAndPlay → downloadSong → SongOkDownloadManager。它内部还留着一条不过最终闸门的 getSongDownloadUrl 调用(:5239) 与 ThunderSDK/4.1.3 的 UA、.tmp 临时文件写法;如果将来复活它,需要补闸门。
  10. MainActivity.download(song)(:4984)完全不取址、不过闸门:直接用 song.downloadUrl/videoUrl 下载。它是"旧版备用"路径,若被曲库条目(Song.remote() 会填 downloadUrl)触发, 就绕过了 AdVideoFilter。这是四条主路径之外的第五条消费点。
  11. DB 回落路径静默吞异常:fetchFromDb 的 catch (_: Exception) {}(SongApiClient.kt:157) 不写任何日志 —— 数据库打不开/查询异常时,外观与"DB 里没有 cloud_url"完全一样。
  12. ensureConfig 探测的是"当前队首"(SongUrlResolver.kt:239 传 allHosts.firstOrNull()), 而队首会被 promote/demote 改变;加上 30 分钟 TTL,实际探测对象在一次会话里可能不是固定那个源。 按当前默认顺序(自建源在首)它返回 trial_times=0 → 不限制源数量;若把带 trial_times=2 的 官方源放首位,则一次取址只会试 2 个源(:240-244)。
  13. AGENTS.md 与代码有轻微漂移:文档里写 KTV_BRIDGE_API 为 13("已升到 13"), 代码与 ktv_api.js 实际是 14(ktv_api.js:15/:535、KtvJsBridge.kt:403/:429)。
  14. AD_MARKERS 现在含 demo(AdVideoFilter.kt:32),只因为匹配范围已收敛到 host + path (不再搜 query)。若将来有人把匹配范围改回整串,?token=…&t=… 里的随机串会误杀真源。
  15. SongUrlResolver.DEFAULT_HOSTS 与 SourceHosts.DEFAULT 是两份重复常量(:62-65 / :31-34), 必须手工保持一致(SourceHosts.DEFAULT 才是 parseList 真正使用的默认值, DEFAULT_HOSTS 只用于 hostOrder 初值与安装日志 :132)。
  16. /push_download 的 markPushed 在发送之前(PushDownload.kt:140-141)—— 一次失败(网络抖动/服务器 5xx)后,同一编号在 5 分钟内不会重推。

12. 仍未确认的部分

  • do.php 返回里的 limit 字段(服务端 service.py:89 返回 2000)在 App 侧没有任何消费方; 其业务语义(是否表示试用次数/时长/并发)未在 App 代码中确认。官方源是否也返回该字段、 以及取值含义,未在代码中确认(需要抓包或问服务方)。
  • /i.php 返回的 retry / ordertype / end_date 字段:App 未读取,真实语义未在 App 代码中确认; heart_time 已在 V1.6.3 被当作 token 缓存时长使用(语义仍以"实测 5400 秒"为准)。
  • AdVideoFilter.replacementUrl(AdVideoFilter.kt:54)的运行期赋值点:全仓未发现任何写入, 即恒为 null(="屏蔽"而非"替换")。是否有外部入口(如远程配置)未在代码中确认。
  • 官方源(zk.ktv1.top:6677)是否支持 /token:代码按 404/405 处理,实测结论未在本次阅读中验证。
  • CONFIG.MWS(https://mws.cherryonline.cn)与 mwsLogin()(ktv_api.js:325-353)在当前取址链路里 没有被调用(KtvApi.init() 直接 return true,:526-529);它是否在别处(远程页面/其它 JS)被使用, 未在本次阅读范围内确认。
  • SongUrlResolver 的 resolution 是否真的存在非 720 的调用方:当前代码里所有调用点都传 "720"; 其它入口(如未来的 1080p 设置)未在代码中确认。

13. 点歌延迟优化(V1.6.3/46,2026-10-15)

13.1 用户现象与真实原因

现象:点歌后要等 3~5 秒才发出(真正能取到地址的那次)歌曲源请求。

真实原因(代码路径 + 127.0.0.1 stub 对照测量):关键路径上串了一堆与"点哪首歌"无关的 一次性开销,其中最大的一条是 JS 里终态失败也固定 sleep(3000) 再重试一次:

// ktv_api.js(改前)
var needRetry = data.code !== 200;          // 403(风控)/404(没有这首)也算 needRetry
if (needRetry) {
  if (tokenRejected) { ... }                 // 令牌被拒才立即重试
  else { await sleep(3000); }                // ← 无条件 3 秒;用户实测的"3~5 秒"就在这里
  data = await self._fetchSongUrl(...);      // 3 秒后才发第二次
}

官方源(zk.ktv1.top:6677)的试用额度只有 trial_times: 2 / 5 分钟(api.php), 额度用完后 do.php 返回 HTTP 200 + 业务码 403(或对每首歌返回同一个广告资源)—— 这两条都会让 JS 白等 3 秒再重试一次,然后才 demoteSource 换到兜底源。 而 promoteSource 会把成功过的源提到队首,于是"官方源成功一首 → 下一首又排第一 → 又白等 3 秒" 会反复出现,与"点歌后 3~5 秒"完全吻合。

其余叠加项(每项都是一次完整往返,全部排在 do.php 之前):

# 位置(改前) 问题
1 SongUrlResolver.resolveSourced → ensureConfig() 每首歌的第一个请求是 POST api.php(当前官方节点没有这个接口,白等一次往返)
2 SongUrlResolver.fetchToken() 没有任何缓存:每首歌、每个源都重打一次 /i.php(弱网下还要算 DNS + TCP + TLS)
3 AppTokenClient.tokenFor() 懒加载:第一次取址才 GET /token(阻塞在 do.php 之前);且网络 I/O 在全局锁里,一个慢源会阻塞其它源
4 KtvJsBridge.getSongUrl 取址前读两次 state.json(sourceHostsForJs() + allowedMediaHostsForJs() 各一次)
5 SongApiClient.resolveSongDownloadUrl 桥就绪闸门最长等 15 秒(老盒子首次点歌会等满)

13.2 改了什么

措施 实现位置
终态失败不退避/不重试;可重试失败退避 800ms(原 3000ms) ktv_api.js isTerminalFailure() / RETRY_BACKOFF_MS;KTV_BRIDGE_API 15 → 16
上游 /i.php token 按 host 缓存(TTL = heart_time,默认 90 分钟,单飞,失败 30s 冷却) SourceTokenCache.kt;SongUrlResolver.fetchToken/loadUpstreamToken
App 令牌按 host 单飞(网络 I/O 不占全局锁) AppToken.kt AppTokenSession.tokenFor
后台预热:JS 会话 + 各源 /i.php + api.php + DNS/TCP/TLS + 各源 /token Preheat.kt(纯逻辑)+ SourcePreheat.kt(接线);触发点:MainActivity.onCreate / onResume / showSearchPage / loadBrowsePage / refreshNetworkState / JS 桥就绪
api.php 移出取址关键路径(后台预热,失败仍进 30 分钟负缓存) SongUrlResolver.preheatConfig()
取址路径只读一次 state.json;配置注入与 window.api 构造收敛到 apiBootstrapJs() KtvJsBridge.sourceConfigForJs() / apiBootstrapJs()
分阶段耗时日志(只写本地) SongFetchTrace.kt(Kotlin 侧)+ ktv_api.js _timing()(JS 侧,桥注入 window.api.__orderAt)

日志形态(<存储根>/logs/ktv.log):

FETCH-TIME 编号=7303235 阶段=点歌→取址任务开始(含 io 池排队) 自点歌=12ms
FETCH-TIME 编号=7303235 阶段=闸门(等 JS 桥就绪) 自点歌=14ms 详情=用时=1ms ready=true
FETCH-TIME 编号=7303235 阶段=点歌→i.php请求发出 自点歌=21ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=i.php返回 自点歌=182ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=点歌→do.php请求发出 自点歌=190ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=do.php返回 自点歌=343ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=点歌→取到地址 自点歌=350ms 源=https://mdmuse.kasugano.cn

(上例数字来自 stub 测量,真机数值看现场日志。)

13.3 对照测量(127.0.0.1 stub,真实脚本 + 真实 Kotlin 逻辑)

  • JS 路径:node maidong-ktv/tools/measure_ktv_js.js(随仓库提交;用 assets/mobile/ktv_api.js 的真实脚本, 把 android.httpGet / android.appToken 换成"记录时间戳 + 同步 sleep(150ms 往返) + 按 host 缓存"的 stub)。 每个场景连点 4 首,第 1 首的"点歌 → 拿到地址":

    场景(官方源首答失败形态) 优化前 优化后 优化后 + 预热
    业务码 403(风控/额度耗尽) 4064ms 907ms 307ms
    业务码 404(该源没有这首) 4059ms 905ms 303ms
    只发广告(code=200 + 广告地址) 4060ms 1858ms 1255ms
    第 3 首(官方源前两首成功、第 3 首起 403) 3757ms 602ms 302ms

    其中"3 秒白等"在时间线上看得很清楚(quota2 第 3 首): 优化前:0ms do.php(官方) → 3155ms do.php(重试,中间就是 sleep 3000) …; 优化后:0ms do.php(官方) → 151ms i.php(兜底源) → 452ms do.php(兜底源)。 另外 4 首的 do.php 请求数在终态失败场景由 6 → 5(少了一次毫无意义的重试)。

  • 预热成本:一轮预热 = 2 个源 × (/i.php + /token) ≈ 4 次往返(stub 150ms → 603ms), 全在后台线程;之后每个触发原因 20 秒节流、缓存命中后不再发请求。

⚠️ 测量的边界:stub 只能证明"JS 逻辑 + 网络往返"的差异;主线程处理、io 池排队、 WebView evaluateJavascript 延迟量不到 —— 那部分由设备端 FETCH-TIME 阶段=点歌→取址任务开始 (含 io 池排队)与 阶段=闸门 两条日志给出,不要用 stub 数字外推到真机。