点歌延迟优化:终态失败不退避 + 上游 i.php token 缓存 + 后台预热 + 分阶段耗时日志(V1.6.3/46,未发布)
This commit is contained in:
@@ -0,0 +1,818 @@
|
||||
# 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 数字外推到真机**。
|
||||
Reference in New Issue
Block a user