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

819 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 一图流
```mermaid
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`):
```kotlin
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`):
```kotlin
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)` 再重试一次**:
```js
// 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 数字外推到真机**。