From c7f8224a7306f1ce8cdd82e5be13459da1be3193 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Wed, 14 Oct 2026 19:25:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=82=B9=E6=AD=8C=E5=BB=B6=E8=BF=9F=E4=BC=98?= =?UTF-8?q?=E5=8C=96=EF=BC=9A=E7=BB=88=E6=80=81=E5=A4=B1=E8=B4=A5=E4=B8=8D?= =?UTF-8?q?=E9=80=80=E9=81=BF=20+=20=E4=B8=8A=E6=B8=B8=20i.php=20token=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=20+=20=E5=90=8E=E5=8F=B0=E9=A2=84=E7=83=AD?= =?UTF-8?q?=20+=20=E5=88=86=E9=98=B6=E6=AE=B5=E8=80=97=E6=97=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=88V1.6.3/46=EF=BC=8C=E6=9C=AA=E5=8F=91=E5=B8=83?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 36 +- app/build.gradle | 4 +- app/src/main/assets/mobile/ktv_api.js | 74 +- app/src/main/java/com/local/ktv/AppToken.kt | 91 +- .../main/java/com/local/ktv/KtvJsBridge.kt | 146 +++- .../main/java/com/local/ktv/MainActivity.kt | 19 +- app/src/main/java/com/local/ktv/Preheat.kt | 158 ++++ .../main/java/com/local/ktv/SongApiClient.kt | 44 + .../main/java/com/local/ktv/SongFetchTrace.kt | 102 +++ .../com/local/ktv/SongOkDownloadManager.kt | 16 +- .../java/com/local/ktv/SongUrlResolver.kt | 151 +++- .../main/java/com/local/ktv/SourcePreheat.kt | 99 +++ .../java/com/local/ktv/SourceTokenCache.kt | 149 ++++ .../test/java/com/local/ktv/AppTokenTest.java | 60 ++ .../test/java/com/local/ktv/FetchTraceTest.kt | 115 +++ .../test/java/com/local/ktv/PreheatTest.kt | 309 +++++++ .../com/local/ktv/SongFetchRetryPolicyTest.kt | 98 +++ .../local/ktv/SourceOrderConsistencyTest.java | 19 +- .../com/local/ktv/SourceTokenCacheTest.kt | 231 +++++ docs/app-song-source-flow.md | 818 ++++++++++++++++++ tools/measure_ktv_js.js | 172 ++++ 21 files changed, 2779 insertions(+), 132 deletions(-) create mode 100644 app/src/main/java/com/local/ktv/Preheat.kt create mode 100644 app/src/main/java/com/local/ktv/SongFetchTrace.kt create mode 100644 app/src/main/java/com/local/ktv/SourcePreheat.kt create mode 100644 app/src/main/java/com/local/ktv/SourceTokenCache.kt create mode 100644 app/src/test/java/com/local/ktv/FetchTraceTest.kt create mode 100644 app/src/test/java/com/local/ktv/PreheatTest.kt create mode 100644 app/src/test/java/com/local/ktv/SongFetchRetryPolicyTest.kt create mode 100644 app/src/test/java/com/local/ktv/SourceTokenCacheTest.kt create mode 100644 docs/app-song-source-flow.md create mode 100644 tools/measure_ktv_js.js diff --git a/AGENTS.md b/AGENTS.md index 2710078..210ddc5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,10 +195,12 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.6.2"` / `versionCode 45` +- `app/build.gradle`:`versionName "1.6.3"` / `versionCode 46` (`1.5.5`/`37` 已发布;`1.5.6` = 更新源加 Gitee、`1.5.7` = 删除「歌曲源临时受限」弹窗、`1.5.8` = 精简远程日志、`1.5.9` = 按 HAR 对齐原版协议、`1.5.10` = 源列表只留 zk.ktv1.top:6677、`1.5.11`(命名越界,已改) / `1.6.0` = whoami 心跳/设备身份/点歌推送/带 query 的地址闸门 + 并入 selfhost-source 支线、 `1.6.1`/`44` = App 接入自建源(自建源优先 / App 令牌 / 媒体允许清单 / 推送默认目标)、 - `1.6.2`/`45` = **取址反转成"官方源优先、自建源兜底" + 只有官方源成功才上报**(含 6 条隐患修复与死代码清理),以上 1.5.6~1.6.2 均尚未发布。18~21 曾被本机临时诊断包占用) + `1.6.2`/`45` = **取址反转成"官方源优先、自建源兜底" + 只有官方源成功才上报**(含 6 条隐患修复与死代码清理)、 + `1.6.3`/`46` = **点歌延迟优化**(终态失败不退避 / 上游 i.php token 缓存 / 后台预热 / 分阶段耗时日志), + 以上 1.5.6~1.6.3 均尚未发布。18~21 曾被本机临时诊断包占用) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **本机 `/tmp` 是 tmpfs,单测必须绕开它**:`/tmp`(5.6G tmpfs)长期被"已删除但仍被打开"的 文件占满,单测写临时文件会抛 `IOException: 超出磁盘配额`(EDQUOT),**看起来像测试失败**。 @@ -354,6 +356,34 @@ $BT/apksigner verify 目标名.apk `SongUrlResolver.sawForbidden` 同口径)。 6. `KTV_BRIDGE_API` 由 14 → **15**(`ktv_api.js` 两处 + `KtvJsBridge` 两处校验; 本次同时把 JS 的取址结果用 `android.onSongSource(requestId, host)` 带回命中的源)。 +- **⚠️ 点歌延迟优化(2026-10-15,V1.6.3/46)**:用户实测"点歌后 3~5 秒才发出取址请求"。 + 根因是**关键路径上排了一串与"点哪首歌"无关的一次性开销**,其中最狠的一条是 JS 里 + **终态失败也固定 `sleep(3000)` 再重试一次**(官方源按出口 IP 限流后,每首歌都要白等 3 秒)。 + 约定(改动这几处时必须同时守住): + 1. **首次请求零等待,退避只用于"还值得再试一次"的失败**:`ktv_api.js` 的 + `isTerminalFailure(httpStatus, code)`(HTTP 4xx / 业务码 403 / 404)→ **不退避、不重试**, + 直接 `demoteSource` 换下一个源;只有广告/错号资源、HTTP 5xx、异常才退避 + `RETRY_BACKOFF_MS`(800ms,原 3000ms)重试一次。`KTV_BRIDGE_API` 15 → **16**。 + 2. **上游 `/i.php` token 按 host 缓存**:`SourceTokenCache`(纯 JVM,有单测), + TTL 取源站响应里的 **`heart_time`**(实测 5400 秒),拿不到用 90 分钟安全默认; + **同一 host 单飞**(同时只发一次),失败 30 秒短冷却。原生兜底 `SongUrlResolver` 必须走它 + —— 以前每首歌、每个源都重打一次 `/i.php`。 + 3. **App 令牌(`/token`)按 host 单飞**:`AppTokenSession` 的网络 I/O 只在**本 host 的门闩**里做 + (以前在全局锁里,一个慢源会把其它源的取令牌一起卡住)。 + 4. **预热 `SourcePreheat`(纯逻辑 `Preheat`)**:App 启动 / JS 桥就绪 / 进入点歌列表 / 回前台 / + 网络恢复时调用 `SourcePreheat.kick(reason)`,在**专用守护线程**(不占 io 池)里预热 + ① JS 会话(`window.api` + 各源 `/i.php`)② 原生 `api.php` 配置 ③ 各源 `/i.php`(TTL 缓存) + ④ DNS+TCP(+TLS)(按 host 60 秒节流)⑤ 各源 `/token`。 + 硬约束:**不阻塞 UI**、**同一时刻只跑一轮**、同一 reason 20 秒节流、**失败只写 `MiliLog.local`**。 + 5. **`api.php` 不再走取址关键路径**:`SongUrlResolver.resolveSourced` 只用**已预热到**的配置; + 请求由 `preheatConfig()` 在后台做(失败仍进 30 分钟负缓存)。 + 6. **分阶段耗时日志**:`SongFetchTrace`(纯 JVM)以点歌编号为键记 `FETCH-TIME 编号=… 阶段=… 自点歌=…ms`, + **只写本地**(`MiliLog.local`);JS 侧同格式由 `_timing()` 输出(桥把点歌时刻注入 + `window.api.__orderAt`,因此量的是"点歌→该阶段"而不是"JS 开始跑之后")。 + ⚠️ 新增阶段日志必须继续走本地,不要上远程(AGENTS「过程写本地」)。 + 7. **`KtvJsBridge.getSongUrl` 的配置注入与 `window.api` 构造收敛到一处** + (`apiBootstrapJs`,取址与预热共用):以前预热要再抄一份,必然漂移。 + 取址路径上读设置只允许**一次** `KtvStore().load()`(`sourceConfigForJs()`)。 - **⚠️ 原版协议对齐(2026-10-08,依据 HAR 抓包实测)**:抓包(Reqable,`e.ac19.cn`)显示原版客户端是**三步**: 1. **`POST /api.php`**:`Content-Type: application/x-www-form-urlencoded`, body `action=get_config&device_id=`,UA @@ -421,7 +451,7 @@ $BT/apksigner verify 目标名.apk 旧实现在 `#fragment` 上会**整条文件名规则失效(漏放广告)**,拿整串搜 `wb66`/`demo` 又会误杀 query 里的签名串("地址不可信"的隐蔽来源)。裸 IP / `pub-*.r2.dev/ss/` 也已收进这道闸门 —— 四条路径不再各判一套。JS 侧同步改(`splitUrl()`), - `KTV_BRIDGE_API` 已升到 **15**;回归用例见 `AdVideoFilterTest`(带 query/fragment 的 + `KTV_BRIDGE_API` 已升到 **16**(15 = 带 query 的地址闸门,16 = 点歌延迟优化);回归用例见 `AdVideoFilterTest`(带 query/fragment 的 真源放行、广告仍拒)与服务端 `tests/test_basic.py::TestQueryStringGate`; 3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整), 所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件** diff --git a/app/build.gradle b/app/build.gradle index a3beccc..479f9b1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 45 - versionName "1.6.2" + versionCode 46 + versionName "1.6.3" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js index e0da427..7dd4e98 100644 --- a/app/src/main/assets/mobile/ktv_api.js +++ b/app/src/main/assets/mobile/ktv_api.js @@ -12,7 +12,7 @@ (function(global) { 'use strict'; - // KTV_BRIDGE_API: 15 (Android 缓存校验标记,请勿删除) + // KTV_BRIDGE_API: 16 (Android 缓存校验标记,请勿删除) // ─── 配置 ────────────────────────────────────────── const CONFIG = { @@ -125,6 +125,33 @@ return new Promise(function(resolve) { setTimeout(resolve, ms); }); } + /** + * 重试退避(毫秒)。**只用在"还值得再试一次"的失败上**,首次请求一律立即发出。 + * + * ⚠️ 2026-10-15 点歌延迟优化:这里原来是固定 3000ms,而且**风控 403 / 业务码 404 / HTTP 4xx + * 也照退避重试**。那两种失败是"终态"的:风控窗口按**出口 IP**记(几秒内不可能放行, + * 密集重试只会加重限流),"该源没有这首"更是重试多少次都一样。 + * 表现就是用户实测的**点歌后 3~5 秒**:首个 `do.php` 在 0.3s 发出、被拒后干等 3 秒, + * 3.3s 才发第二次,再被拒才换源。现在终态失败**不退避、不重试**,直接换下一个源; + * 只有"广告/错号资源"这类可能瞬时抖动的失败才退避重试一次(800ms)。 + */ + const RETRY_BACKOFF_MS = 800; + + /** + * 该失败是不是"终态"(重试没有意义,应直接换下一个源)。 + * + * * HTTP 4xx:请求本身被源站拒绝(403 风控 / 404 没有)、同 token 重试必然一样; + * * 业务码 403:源站风控的真实形态(HTTP 200 + `{"code":403,...}`,2026-10-08 实测); + * * 业务码 404:源站约定用它表达"这首还没下载完成/不在库"(service.py)。 + * + * ⚠️ 与 Kotlin 侧 `SongUrlResolver.sawForbidden` / `KtvJsBridge.riskForbidden` 同口径。 + */ + function isTerminalFailure(httpStatus, code) { + var status = Number(httpStatus || 0); + if (status >= 400 && status < 500) return true; + return code === 403 || code === 404; + } + function nativeResponse(raw) { var value = JSON.parse(raw || '{}'); if (value.error) throw new Error(value.error); @@ -301,6 +328,23 @@ KtvApi.prototype = { _log: function(msg) { if (this.debug) console.log('[KtvApi]', msg); }, + /** + * 分阶段耗时日志(**只走 `android.log` → 本地 ktv.log**,不上报远程)。 + * + * `__orderAt` 由桥在发起本次取址前注入(= 用户点歌那一刻的 `Date.now()` 基准), + * 因此这里量出来的是**用户感知的"点歌 → 该阶段"时长**,而不是"JS 开始跑之后多久"。 + * 没有注入时(例如远程脚本单独调试)什么都不记,不会污染日志。 + */ + _timing: function(stage, host) { + try { + if (!this.__orderAt) return; + var ms = Date.now() - this.__orderAt; + if (global.android && typeof global.android.log === 'function') { + global.android.log('FETCH-TIME 阶段=' + stage + ' 自点歌=' + ms + 'ms 源=' + (host || '')); + } + } catch(e) { /* 计时失败不影响取址 */ } + }, + /** 将失败的源移到优先级队尾(自动排序)。host 不在列表时忽略。 */ demoteSource: function(host) { var idx = this.sourceOrder.indexOf(host); @@ -379,8 +423,11 @@ var sign = md5(params + CONFIG.APP_KEY); var url = host + '/i.php?' + params + '&sign=' + sign; self._log('Getting token: ' + host); + // 阶段日志:这一步才是"点歌后真正发出的第一个取址请求"。 + self._timing('点歌→i.php请求发出', host); try { var resp = await httpGet(url); + self._timing('i.php返回', host); if (resp.status === 200) { var data = parseJsonBody(resp.body); if (data.code === 200) { @@ -420,8 +467,10 @@ var sign = md5(params + CONFIG.SDK_KEY); // App 令牌:自建源先 GET /token(原生侧缓存),有令牌才多带一个 X-App-Token 头; // 官方源(404/405)恒为空对象,请求形态与改造前**逐字节一致**。 + this._timing('点歌→do.php请求发出', host); var resp = await httpGet(host + '/music/do.php?' + params + '&sign=' + sign, null, appHeaders || appTokenHeader(host)); + this._timing('do.php返回', host); if (resp.status !== 200) { return {code: null, data: '', httpStatus: resp.status, body: resp.body}; } @@ -474,11 +523,14 @@ var sentAppToken = !!appHeaders['X-App-Token']; var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders); // token 恒定不变; do.php 403"检测到异常行为"是风控限流, 与 token 过期无关。 - // 403/失败/广告命中时同 token 短退避重试一次, 不再清 token 重取 i.php。 - // 广告/错号资源(如 wb66 719.ts)也算失败, 退避重试一次等真源。 + // ⚠️ 2026-10-15 点歌延迟优化:**首次请求永远立即发出**(上面那一行就是首次请求), + // 这里只决定"要不要再试一次": + // * 终态失败(风控 403 / 业务码 404 / HTTP 4xx)→ **不退避、不重试**,直接换下一个源 + // (旧实现固定 `sleep(3000)` 再重试一次 —— 那就是点歌后干等 3 秒的直接原因); + // * 可重试失败(广告/错号资源、HTTP 5xx、异常)→ 退避 RETRY_BACKOFF_MS 后重试一次。 var needRetry = data.code !== 200; // App 令牌被拒(401 + 令牌无效或已过期):清原生缓存 → 重取 → **立即**重试一次, - // 不走 3 秒退避(那是留给风控/广告资源等真源的)。与 Kotlin 侧 withToken 同一口径。 + // 不走退避(那是留给风控/广告资源等真源的)。与 Kotlin 侧 withToken 同一口径。 var tokenRejected = sentAppToken && ( data.httpStatus === 401 || (data.body && String(data.body).indexOf('令牌无效或已过期') >= 0)); @@ -492,11 +544,16 @@ invalidateAppToken(host); appHeaders = appTokenHeader(host); self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; App 令牌被拒 → 重取后只重试一次'); + data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders); + } else if (isTerminalFailure(data.httpStatus, data.code)) { + self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + + '; 终态失败(不退避、不重试)→ 换下一个源'); } else { - self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; retry once with same token'); - await sleep(3000); + self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + + '; retry once with same token after ' + RETRY_BACKOFF_MS + 'ms'); + await sleep(RETRY_BACKOFF_MS); + data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders); } - data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders); } if (data.code === 200) { @@ -510,6 +567,7 @@ hostOk = true; // 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。 reportSongResult(true, host, musicno, 'ok'); + self._timing('点歌→取到地址', host); return songUrl; } if (isDemoUrl(songUrl, musicno)) { @@ -547,7 +605,7 @@ // ─── 导出 ────────────────────────────────────────── global.KtvApi = KtvApi; global.KtvApiConfig = CONFIG; - global.KtvBridgeApiVersion = 15; + global.KtvBridgeApiVersion = 16; if (typeof module !== 'undefined' && module.exports) { module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; diff --git a/app/src/main/java/com/local/ktv/AppToken.kt b/app/src/main/java/com/local/ktv/AppToken.kt index d6d134b..870fece 100644 --- a/app/src/main/java/com/local/ktv/AppToken.kt +++ b/app/src/main/java/com/local/ktv/AppToken.kt @@ -112,6 +112,12 @@ class AppTokenSession( private val retryAt = HashMap() /** 单测用:每个 host 实际请求过几次 `/token`。 */ private val fetches = HashMap() + /** + * 每个 host 一把门闩:**同一 host 同时只发一次 `/token`**(其余调用等它的结果), + * 但**不同 host 之间互不阻塞** —— 一把全局锁会把"一个慢源"变成"所有源的取令牌都排队" + * (SSO 场景下就是点歌整体变慢)。 + */ + private val gates = HashMap() /** 单参构造:用系统时钟(生产路径)。 */ constructor(get: (String) -> TokenHttp) : this(get, { System.currentTimeMillis() }) @@ -121,43 +127,64 @@ class AppTokenSession( * * 返回 **null = 这次不要带 `X-App-Token`**(源不支持 / 暂时取不到 / 已有缓存但过期且取新失败); * 请求照常发出(服务端 `app_token_enforce=false` 时老请求形态依旧可用)。 + * + * ⚠️ 网络 I/O 只在**本 host 的门闩**里做(不在全局锁里):同一 host 不会重复请求, + * 不同 host 不会互相排队。 */ - fun tokenFor(host: String): String? = synchronized(lock) { + fun tokenFor(host: String): String? { val key = key(host) ?: return null - if (unsupported.contains(key)) return null // 官方源:试过一次就够了 - val now = clock() - tokens[key]?.let { entry -> - if (now < entry.expiresAtMs) return entry.token // 未过期 → 直接复用 + freshToken(key)?.let { return it } + val gate = synchronized(lock) { gates.getOrPut(key) { Any() } } + synchronized(gate) { + // 双检:等门闩期间别人可能已经取回来了。 + freshToken(key)?.let { return it } + synchronized(lock) { + if (unsupported.contains(key)) return null // 官方源:试过一次就够了 + if (clock() < (retryAt[key] ?: 0L)) return null // 冷却中:不带令牌,也不发请求 + fetches[key] = (fetches[key] ?: 0) + 1 + } + val now = clock() + val resp = runCatching { get(AppToken.endpoint(key)) }.getOrNull() + // 网络异常/超时:只写日志(调用方负责),这里静默降级成"不带令牌",绝不影响取址与播放。 + if (resp == null) { + synchronized(lock) { retryAt[key] = clock() + AppToken.FAILURE_BACKOFF_MS } + return null + } + if (AppToken.isUnsupportedStatus(resp.status)) { + synchronized(lock) { + unsupported.add(key) + tokens.remove(key) + retryAt.remove(key) + } + return null + } + if (resp.status !in 200..299) { + synchronized(lock) { retryAt[key] = clock() + AppToken.FAILURE_BACKOFF_MS } + return null + } + val token = AppToken.parseToken(resp.body) + if (token == null) { + synchronized(lock) { retryAt[key] = clock() + AppToken.FAILURE_BACKOFF_MS } + return null + } + val ttlMs = ((AppToken.parseExpiresIn(resp.body) ?: AppToken.DEFAULT_EXPIRES_IN_SEC) * 1000L - + AppToken.EARLY_EXPIRE_MS).coerceAtLeast(1_000L) + synchronized(lock) { + tokens[key] = Entry(token, now + ttlMs) + retryAt.remove(key) + } + return token + } + } + + /** 未过期就返回缓存令牌(过期则顺手清掉)。 */ + private fun freshToken(key: String): String? { + synchronized(lock) { + val entry = tokens[key] ?: return null + if (clock() < entry.expiresAtMs) return entry.token tokens.remove(key) - } - if (now < (retryAt[key] ?: 0L)) return null // 冷却中:不带令牌,也不发请求 - fetches[key] = (fetches[key] ?: 0) + 1 - val resp = runCatching { get(AppToken.endpoint(key)) }.getOrNull() - // 网络异常/超时:只写日志(调用方负责),这里静默降级成"不带令牌",绝不影响取址与播放。 - if (resp == null) { - retryAt[key] = now + AppToken.FAILURE_BACKOFF_MS return null } - if (AppToken.isUnsupportedStatus(resp.status)) { - unsupported.add(key) - tokens.remove(key) - retryAt.remove(key) - return null - } - if (resp.status !in 200..299) { - retryAt[key] = now + AppToken.FAILURE_BACKOFF_MS - return null - } - val token = AppToken.parseToken(resp.body) - if (token == null) { - retryAt[key] = now + AppToken.FAILURE_BACKOFF_MS - return null - } - val ttlMs = ((AppToken.parseExpiresIn(resp.body) ?: AppToken.DEFAULT_EXPIRES_IN_SEC) * 1000L - - AppToken.EARLY_EXPIRE_MS).coerceAtLeast(1_000L) - tokens[key] = Entry(token, now + ttlMs) - retryAt.remove(key) - return token } /** 清掉某源的令牌缓存(**401** 时调用)。 */ diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 9292e82..dd75bba 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -72,7 +72,7 @@ class KtvJsBridge(context: Context) { })()""" /** 热更新脚本的兼容标记(`ktv_api.js` 两处 + 本类两处校验,改协议必须一起改)。 */ - private const val BRIDGE_API_MARKER = "KTV_BRIDGE_API: 15" + private const val BRIDGE_API_MARKER = "KTV_BRIDGE_API: 16" /** * 已知 CDN 域名后缀(**白名单**,仅用于 [isTrustedDownloadUrl] 的最后一步)。 @@ -372,18 +372,25 @@ class KtvJsBridge(context: Context) { * * 与原生兜底 [SongUrlResolver.applySourceHosts] 读的是**同一份设置**,两条取址路径的源一致。 */ - private fun sourceHostsForJs(): List = runCatching { - val store = KtvStore() - store.load() - store.sourceHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT - }.getOrDefault(SourceHosts.DEFAULT) + private fun sourceHostsForJs(): List = sourceConfigForJs().first /** 设置项里的媒体地址允许清单(注入 JS 的 `CONFIG.ALLOWED_MEDIA_HOSTS`)。 */ - private fun allowedMediaHostsForJs(): List = runCatching { + private fun allowedMediaHostsForJs(): List = sourceConfigForJs().second + + /** + * **一次**读盘取出「源列表 + 媒体地址允许清单」。 + * + * 取址关键路径上([getSongUrl])必须用这个版本:旧写法两个 getter 各调一次 + * `KtvStore().load()`,等于在同一首歌的取址前面串两次 `state.json` 读取 + JSON 解析。 + */ + private fun sourceConfigForJs(): Pair, List> = runCatching { val store = KtvStore() store.load() - store.allowedMediaHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS - }.getOrDefault(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS) + val hosts = store.sourceHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT + val allowed = store.allowedMediaHosts.takeIf { it.isNotEmpty() } + ?: SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS + hosts to allowed + }.getOrDefault(SourceHosts.DEFAULT to SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS) /** * 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。 @@ -402,36 +409,14 @@ class KtvJsBridge(context: Context) { } } - fun getSongUrl( - musicNo: String, - resolution: String, - h265: Boolean, - callback: (String?, String?) -> Unit, - ) { - if (!ready || destroyed) { - callback(null, null) - return - } - val requestId = UUID.randomUUID().toString() - callbacks[requestId] = callback - // 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底): - // 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。 - val hosts = sourceHostsForJs() - val allowedHosts = allowedMediaHostsForJs() - val script = """ - (function() { - var requestId = ${JSONObject.quote(requestId)}; - var completed = false; - function finish(url, host) { - if (completed) return; - completed = true; - clearTimeout(timer); - // 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。 - try { android.onSongSource(requestId, host || ''); } catch (e) {} - android.onUrl(requestId, url || ''); - } - var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS); - Promise.resolve().then(async function() { + /** + * `window.KtvApiConfig` 注入 + `window.api` / `window._apiInitPromise` 构造。 + * + * ⚠️ **取址与预热共用同一段 JS**([getSongUrl] 与 [warmUpSession]): + * 以前这段只写在取址脚本里,预热再抄一份就必然漂移(协议/配置注入是四两拨千斤的地方)。 + * 调用方必须把它放在 `async function` 里(内部 `await`)。 + */ + private fun apiBootstrapJs(hosts: List, allowedHosts: List): String = """ if (!window.api) { // 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。 // 使用本机当前网卡 mac + 抓包注册的 sn(会话内保持稳定),并保持版本号一致。 @@ -461,6 +446,89 @@ class KtvJsBridge(context: Context) { }); } await window._apiInitPromise; + """.trimIndent() + + /** + * **取址预热**:把 JS 会话与各源的 `/i.php` token 提前在后台热好(App 启动 / 进入歌曲列表 / + * 回到前台时调用,见 [SourcePreheat])。 + * + * 为什么有效:JS 路径的第一次取址原本要现做「构造 `window.api` → `GET /i.php`(DNS + TCP + + * TLS + 往返)」;这一步与"点的是哪首歌"无关,完全可以提前做掉。 + * 预热脚本跑完会写一条 `PREHEAT-JS ...` 本地日志,方便现场核对到底热没热。 + * + * @return true = 脚本已交给 WebView(**会真的发请求**);false = 桥不可用,调用方应改走原生预热 + */ + fun warmUpSession(hosts: List): Boolean { + val view = webView + if (!ready || destroyed || view == null || hosts.isEmpty()) return false + val allowedHosts = sourceConfigForJs().second + val script = """ + (function() { + Promise.resolve().then(async function() { + var __preheatAt = Date.now(); + ${apiBootstrapJs(hosts, allowedHosts)} + var hosts = ${JSONArray(hosts).toString()}; + for (var i = 0; i < hosts.length; i++) { + try { + var token = await window.api.getToken(hosts[i], false); + android.log('PREHEAT-JS i.php token ' + (token ? 'ok' : 'fail') + + ' 源=' + hosts[i] + ' 用时=' + (Date.now() - __preheatAt) + 'ms'); + } catch (e) { + android.log('PREHEAT-JS 源=' + hosts[i] + ' 失败 ' + (e && e.message ? e.message : e)); + } + } + android.log('PREHEAT-JS 完成 用时=' + (Date.now() - __preheatAt) + 'ms'); + }).catch(function(error) { + android.log('PREHEAT-JS 失败 ' + (error && error.message ? error.message : error)); + }); + })(); + """.trimIndent() + return mainHandler.post { + runCatching { webView?.evaluateJavascript(script, null) } + .onFailure { MiliLog.localWarn("PREHEAT JS 会话预热失败 ${it.javaClass.simpleName}: ${it.message}") } + } + } + + fun getSongUrl( + musicNo: String, + resolution: String, + h265: Boolean, + callback: (String?, String?) -> Unit, + ) { + if (!ready || destroyed) { + callback(null, null) + return + } + val requestId = UUID.randomUUID().toString() + callbacks[requestId] = callback + // 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底): + // 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。 + // ⚠️ 设置只读**一次**盘(以前 sourceHostsForJs + allowedMediaHostsForJs 各读一遍 + // state.json,两次解析都在取址关键路径上)。 + val config = sourceConfigForJs() + val hosts = config.first + val allowedHosts = config.second + // 阶段计时基准:把"用户点歌那一刻"的 JS 时间戳注入脚本,JS 侧的 _timing() 才能量出 + // **点歌 → i.php/do.php 请求发出**的真实耗时(否则只能量到"JS 开始跑之后多久")。 + val orderAt = SongFetchTrace.elapsedMs(musicNo) + ?.let { System.currentTimeMillis() - it } ?: 0L + val script = """ + (function() { + var requestId = ${JSONObject.quote(requestId)}; + var completed = false; + function finish(url, host) { + if (completed) return; + completed = true; + clearTimeout(timer); + // 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。 + try { android.onSongSource(requestId, host || ''); } catch (e) {} + android.onUrl(requestId, url || ''); + } + var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS); + Promise.resolve().then(async function() { + ${apiBootstrapJs(hosts, allowedHosts)} + // 分阶段耗时日志的基准(0 = 没有点歌记录,JS 侧不记任何计时行)。 + window.api.__orderAt = $orderAt; var url = await window.api.getSongUrl( ${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"} ); diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 6ce676d..548b95f 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -576,6 +576,9 @@ class MainActivity : AppCompatActivity() { } // Keep initialization on the loading state until the external catalog is ready. SongApiClient.init(this) // 主线程初始化 JS Bridge + // 取址预热(后台、不阻塞启动):把第一个源的 App 令牌 / 上游 i.php token / DNS·TCP·TLS + // 提前热好,用户第一次点歌就不必等这些一次性开销(见 SourcePreheat)。 + SourcePreheat.kick(Preheat.REASON_STARTUP) initializeDatabase() startRemoteServer() updateMobileQrOverlay() @@ -802,6 +805,8 @@ class MainActivity : AppCompatActivity() { override fun onResume() { super.onResume() + // 回到前台:长时间挂后台后连接/令牌可能已经凉了,后台补一次取址预热(非阻塞、失败只写本地日志)。 + SourcePreheat.kick(Preheat.REASON_RESUME) } @Deprecated("Deprecated in Android") @@ -3771,8 +3776,10 @@ class MainActivity : AppCompatActivity() { toast("重新生成设备身份失败:${error.message}") return } - // 旧 token 绑在旧 device 上:清掉歌曲源会话,下一次取址用新身份重新登录。 + // 旧 token 绑在旧 device 上:清掉歌曲源会话 + 原生侧的上游 /i.php 缓存, + // 下一次取址用新身份重新登录。 runCatching { SongApiClient.clearTokenCache() } + runCatching { SongApiClient.clearUpstreamTokens() } runCatching { sendDeviceHeartbeat(boot = false) } // 后台线程里发,不阻塞界面 MiliLog.local( "DEVICE-ID 已重新生成 模板=${ident.preset} mac=${ident.mac} sn=${ident.sn} " + @@ -3829,7 +3836,12 @@ class MainActivity : AppCompatActivity() { DeviceMac.refresh() refreshLogDeviceContext() SongApiClient.resetSourceSession() + // 换了出口 IP:上游 /i.php 的 token 与源站的试用窗口一样,都可能已经失效 + // (源站按「出口 IP + device」识别)→ 清掉原生侧的上游 token 缓存,强制重取。 + SongApiClient.clearUpstreamTokens() Eac19WindowGuard.noteUrlOk() + // 网络刚恢复/换网:连接与令牌都是凉的,后台补一次取址预热(非阻塞)。 + SourcePreheat.kick(Preheat.REASON_NETWORK) updateMobileQrOverlay() updateFullScreenQrOverlay() val retried = SongOkDownloadManager.retryNetworkFailed() @@ -7596,6 +7608,9 @@ class MainActivity : AppCompatActivity() { /** 顶栏搜索按钮 - 跳到搜索页 */ private fun showSearchPage() { + // 进入点歌/歌曲列表界面:用户已经在挑歌,马上就会点 —— 后台把取址要用的东西预热好 + // (非阻塞、失败只写本地日志,见 SourcePreheat)。 + SourcePreheat.kick(Preheat.REASON_SONG_LIST) searchScope = "song" activeSearchQuery = "" setSearchText("") @@ -9791,6 +9806,8 @@ class MainActivity : AppCompatActivity() { * 加载浏览分页数据 */ private fun loadBrowsePage() { + // 浏览/点歌列表页:进入即后台预热取址链路(节流 + 单飞,重复调用近乎零成本)。 + SourcePreheat.kick(Preheat.REASON_SONG_LIST) if (!library.muse.isAvailable()) { catalogRefreshPending = true busy(true, "曲库加载中...") diff --git a/app/src/main/java/com/local/ktv/Preheat.kt b/app/src/main/java/com/local/ktv/Preheat.kt new file mode 100644 index 0000000..9a73360 --- /dev/null +++ b/app/src/main/java/com/local/ktv/Preheat.kt @@ -0,0 +1,158 @@ +package com.local.ktv + +/** + * 取址**预热**的纯逻辑:节流 + 单飞 + 步骤编排(**纯 JVM**,不碰 Android API → 可单测)。 + * + * ## 为什么要预热 + * 用户点歌到"歌曲源请求真的发出"之间,原先串着一堆**一次性**开销: + * 源站 `/i.php` 的往返、自建源 `/token` 的往返(10 分钟有效,却懒到第一次取址才去取)、 + * 以及 DNS 解析 + TCP 握手(HTTPS 还要多一次 TLS 握手)。这些都不依赖"点的是哪首歌", + * 因此可以在用户还没点歌时**提前在后台做掉**,把这几百毫秒到几秒从点歌关键路径上摘掉。 + * + * ## 硬约束(本类的全部行为) + * 1. **绝不阻塞调用方**:`kick()` 只做"判断 + 交给后台线程",立刻返回(UI 线程可以放心调用); + * 2. **同一时刻只跑一轮**(单飞):预热本身会发请求,堆叠调用会变成对源站的密集探测 + * (源站按出口 IP 限流,见 [Eac19WindowGuard]); + * 3. **同一触发原因节流**:同一个 reason 在 [minIntervalMs] 内只跑一轮; + * 4. **失败只写日志**:任何一步抛异常都只记一条本地日志,**绝不外抛**(预热失败绝不影响点歌); + * 5. **步骤有先后**:先做"会真的发出 HTTP 请求"的那一步(它顺带把 DNS/TCP/TLS 都热好), + * 只有当它没发请求时才补一次裸传输预热 —— 避免对源站重复建连。 + * + * 生产接线见 [SourcePreheat];本类只负责"什么时候跑、按什么顺序跑、失败怎么办"。 + * + * @param hosts 当前源顺序(**按顺序预热前 [MAX_WARM_HOSTS] 个源**:官方源优先, + * 但它一旦被限流,兜底源才是真正被用到的那个,所以不能只热第一个) + * @param runOn 后台执行器(生产 = 专用守护线程池;测试 = 可注入的同步/阻塞执行器) + * @param warmJsSession 预热 JS 会话(构造 `window.api` + 逐个源取一次 `/i.php` token)。 + * **返回 true = 这次真的发出了 HTTP 请求**(DNS/TCP/TLS 已随之热好) + * @param warmUpstreamToken 预热原生侧的 `/i.php` token([SourceTokenCache],带 TTL) + * @param warmTransport 裸传输预热(DNS 解析 + TCP 连接,HTTPS 再握一次手) + * @param warmAppToken 预热自建源 App 令牌(`GET /token`,[AppTokenClient] 内部有缓存) + * @param warmSourceConfig 预热源站 `api.php` 配置(每轮只做一次;它以前**同步**卡在取址最前面) + */ +class Preheat( + private val hosts: () -> List, + private val runOn: (Runnable) -> Unit, + private val warmJsSession: (List) -> Boolean, + private val warmUpstreamToken: (String) -> Unit, + private val warmTransport: (String) -> Unit, + private val warmAppToken: (String) -> Unit, + private val warmSourceConfig: () -> Unit = {}, + private val clock: () -> Long = { System.currentTimeMillis() }, + private val log: (String) -> Unit = { MiliLog.local(it) }, + private val minIntervalMs: Long = DEFAULT_MIN_INTERVAL_MS, +) { + + private val lock = Any() + private var running = false + /** 每个触发原因各自节流(`startup` / `js-ready` / `song-list` / `resume` …)。 */ + private val lastRunAtByReason = HashMap() + /** 单测用:实际跑过的轮数。 */ + @Volatile private var rounds = 0 + + /** + * 触发一轮预热(**非阻塞**)。 + * + * @return true = 这一轮已交给后台;false = 正在跑 / 该原因在节流窗口内 / 后台拒绝执行。 + */ + fun kick(reason: String): Boolean { + val now = clock() + synchronized(lock) { + if (running) return false + val last = lastRunAtByReason[reason] ?: 0L + if (now - last < minIntervalMs) return false + lastRunAtByReason[reason] = now + running = true + } + val task = Runnable { + try { + runOnce(reason) + } catch (_: Throwable) { + // runOnce 内部已逐步兜底;这里再兜一层,绝不让异常逃到后台线程之外。 + } finally { + synchronized(lock) { running = false } + } + } + return try { + runOn(task) + true + } catch (t: Throwable) { + // 执行器拒绝(线程池满/已关闭)也不能影响调用方。 + synchronized(lock) { running = false } + log("PREHEAT 调度失败 reason=$reason ${t.javaClass.simpleName}: ${t.message}") + false + } + } + + /** 是否有一轮正在后台跑(单测/自检用)。 */ + fun isRunning(): Boolean = synchronized(lock) { running } + + /** 实际完成/开始的轮数(单测用)。 */ + fun roundCount(): Int = rounds + + /** 同步跑一轮(**仅供单测**:生产必须走 [kick],否则就阻塞调用方了)。 */ + fun runOnceForTest(reason: String) = runOnce(reason) + + private fun runOnce(reason: String) { + rounds++ + val startedAt = clock() + val warmHosts = runCatching { hosts().take(MAX_WARM_HOSTS) }.getOrDefault(emptyList()) + if (warmHosts.isEmpty()) { + log("PREHEAT 跳过 reason=$reason(没有可用源)") + return + } + // ① 会真的发出 HTTP 请求的那一步(JS 就绪时优先):它顺带把 DNS/TCP/TLS 与 + // JS 侧的 token 缓存都热好,因此**只有它没发请求时**才补原生侧/裸传输预热。 + var httpWarmed = false + step(reason, warmHosts.first(), "js-session") { httpWarmed = warmJsSession(warmHosts) } + // 源站 api.php 配置:旧实现**同步**走在每次取址的最前面(首个请求不是 /i.php 而是它), + // 现在挪到这里(30 分钟 TTL,幂等)。 + step(reason, warmHosts.first(), "source-config") { warmSourceConfig() } + for (host in warmHosts) { + if (!httpWarmed) { + // JS 桥不可用/未就绪时,原生兜底路径才是主力:热原生侧的 token 缓存 + // (每个源一次,之后由 TTL 缓存 + 单飞兜住,重复预热近乎零成本)。 + step(reason, host, "upstream-token") { warmUpstreamToken(host) } + } + // 裸传输预热:DNS 解析 + TCP 连接(HTTPS 再握一次手)。每次取址都是**新连接** + // (干净请求不做 keep-alive),所以这里热的是 DNS 缓存与 TLS 会话票据。 + step(reason, host, "transport") { warmTransport(host) } + // 自建源的 App 令牌(10 分钟有效)。官方源第一次会拿到 404/405 并被 + // AppTokenSession 永久记住"不支持",之后连请求都不发。 + step(reason, host, "app-token") { warmAppToken(host) } + } + log( + "PREHEAT 完成 reason=$reason 源=${warmHosts.joinToString(" > ")} " + + "用时=${clock() - startedAt}ms http预热=$httpWarmed", + ) + } + + private fun step(reason: String, host: String, name: String, action: () -> Unit) { + val at = clock() + try { + action() + log("PREHEAT 步骤 ok reason=$reason 步骤=$name 源=$host 用时=${clock() - at}ms") + } catch (t: Throwable) { + // 预热失败只写本地日志:绝不影响取址/播放(预热本来就是"尽力而为")。 + log( + "PREHEAT 步骤失败 reason=$reason 步骤=$name 源=$host " + + "${t.javaClass.simpleName}: ${t.message}", + ) + } + } + + companion object { + /** 同一个触发原因的节流窗口。 */ + const val DEFAULT_MIN_INTERVAL_MS = 20_000L + + /** 一轮预热最多预热几个源(防止源列表很长时一次打出太多请求)。 */ + const val MAX_WARM_HOSTS = 3 + + /** 触发原因(远程日志/本地日志里能看到到底是哪一次触发的)。 */ + const val REASON_STARTUP = "startup" + const val REASON_JS_READY = "js-ready" + const val REASON_SONG_LIST = "song-list" + const val REASON_RESUME = "resume" + const val REASON_NETWORK = "network-up" + } +} diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index ba26f98..25503db 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -36,6 +36,9 @@ object SongApiClient { ready = ok // 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。 Log.i(TAG, "JS Bridge init: $ok") + // 状态跃迁:桥刚就绪时补一次预热 —— 启动那次预热通常发生在 WebView 就绪**之前**, + // 走的还是原生兜底路径;这里补上"JS 会话 + 各源 /i.php token"的预热。 + if (ok) runCatching { SourcePreheat.kick(Preheat.REASON_JS_READY) } } } @@ -121,6 +124,7 @@ object SongApiClient { // 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备 // (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。 // 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。 + val gateStartedAt = System.currentTimeMillis() val deadline = System.currentTimeMillis() + 15_000L while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) { try { Thread.sleep(100) } catch (_: InterruptedException) { @@ -128,6 +132,11 @@ object SongApiClient { break } } + // 阶段日志:这一段就是"点歌 → 真正发请求"之间可能白等的地方(桥就绪闸门)。 + SongFetchTrace.stage( + musicno, "闸门(等 JS 桥就绪)", + "用时=${System.currentTimeMillis() - gateStartedAt}ms ready=${bridge.isReady()}", + ) if (!bridge.isReady()) { // ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是 // 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器, @@ -141,6 +150,7 @@ object SongApiClient { } val result = AtomicReference() val latch = CountDownLatch(1) + val jsStartedAt = System.currentTimeMillis() bridge.getSongUrl(musicno, resolution, h265) { url, sourceHost -> // JS 同时回传"本次命中的源"(第二参)→ 构造**显式来源标志**。 result.set(url?.takeIf { it.isNotEmpty() }?.let { SourcedSongUrl(it, sourceHost.orEmpty()) }) @@ -152,6 +162,11 @@ object SongApiClient { } catch (_: InterruptedException) { Thread.currentThread().interrupt() } + // 阶段日志:JS 全程耗时(含它自己的 i.php / do.php / 退避)。 + SongFetchTrace.stage( + musicno, "JS 会话取址全程", + "用时=${System.currentTimeMillis() - jsStartedAt}ms 有地址=${!result.get()?.url.isNullOrEmpty()}", + ) // 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会, // 避免 JS 侧偶发失败直接把第一首歌判死。 val resolved = SongSourceFlow.firstAvailable( @@ -163,6 +178,7 @@ object SongApiClient { // 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。 Eac19WindowGuard.noteUrlOk() } + SongFetchTrace.stage(musicno, "取址完成", "命中=${resolved?.sourceHost.orEmpty()}") return resolved } @@ -264,4 +280,32 @@ object SongApiClient { @JvmStatic fun lastSongSourceHost(): String = bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost + + // ─── 取址预热(由 SourcePreheat 在后台调用,见那段注释:绝不阻塞、失败只写本地日志)─── + + /** + * 预热 JS 会话:构造 `window.api` 并逐个源取一次 `/i.php` token。 + * + * @return true = 脚本已交给 WebView(会真的发出 HTTP 请求,DNS/TCP/TLS 随之热好) + */ + @JvmStatic + fun warmUpJsSession(hosts: List): Boolean = + runCatching { bridge?.warmUpSession(hosts) ?: false } + .onFailure { MiliLog.localWarn("PREHEAT JS 会话预热异常 ${it.javaClass.simpleName}: ${it.message}") } + .getOrDefault(false) + + /** 预热原生兜底路径的上游 `/i.php` token(带 TTL 缓存 + 单飞)。 */ + @JvmStatic + fun warmUpstreamToken(host: String): String? = SongUrlResolver.preheatUpstreamToken(host) + + /** 预热源站 `api.php` 配置(把它从取址关键路径上挪走;见 [SongUrlResolver.preheatConfig])。 */ + @JvmStatic + fun warmSourceConfig() { + runCatching { SongUrlResolver.preheatConfig() } + .onFailure { MiliLog.localWarn("PREHEAT api.php 配置预热异常 ${it.javaClass.simpleName}: ${it.message}") } + } + + /** 清掉原生侧的上游 token 缓存(换设备身份后调用)。 */ + @JvmStatic + fun clearUpstreamTokens() = SongUrlResolver.clearUpstreamTokens() } diff --git a/app/src/main/java/com/local/ktv/SongFetchTrace.kt b/app/src/main/java/com/local/ktv/SongFetchTrace.kt new file mode 100644 index 0000000..748946b --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongFetchTrace.kt @@ -0,0 +1,102 @@ +package com.local.ktv + +import java.util.LinkedHashMap + +/** + * 取址链路的**分阶段耗时**记录(纯 JVM,可单测)。 + * + * ## 为什么需要 + * "点歌后要等几秒才发出取址请求"这类问题,只看"成功/失败"的日志是定位不了的: + * 必须知道**每一段**各花了多久 —— 主线程处理、io 池排队、桥就绪等待、`/i.php`、 + * `/music/do.php`、地址闸门。这个类就是那条时间轴。 + * + * ## 约定 + * * 全部走 [MiliLog.local](**只写本地** `<存储根>/logs/ktv.log`)—— + * 它是**过程**日志,按 AGENTS「远程日志只留结论、过程走本地」的约定不该上远程; + * * 以**点歌编号**为键:点歌在主线程开始,取址在 io 线程、JS 在 WebView 线程, + * 跨线程传递时间戳只能靠一个共享的键值表(不改任何函数签名); + * * 有界([maxEntries]),满了丢最旧的,绝不无限增长。 + */ +class FetchTrace( + private val clock: () -> Long = { System.currentTimeMillis() }, + private val sink: (String) -> Unit = { MiliLog.local(it) }, + private val maxEntries: Int = 32, +) { + + private val lock = Any() + /** 点歌编号 → 本轮"点歌时刻"(毫秒)。LinkedHashMap 保证能按插入顺序淘汰最旧的。 */ + private val orderAtById = LinkedHashMap() + + /** 记录一次点歌(同一编号重复点歌时以**最后一次**为准:那就是本轮取址的起点)。 */ + fun beginOrder(id: String?) { + val key = normalize(id) ?: return + synchronized(lock) { + orderAtById.remove(key) + orderAtById[key] = clock() + while (orderAtById.size > maxEntries) { + val oldest = orderAtById.keys.firstOrNull() ?: break + orderAtById.remove(oldest) + } + } + } + + /** 距离本轮点歌已经过去多久(没有记录时返回 null)。 */ + fun elapsedMs(id: String?): Long? { + val key = normalize(id) ?: return null + val at = synchronized(lock) { orderAtById[key] } ?: return null + return (clock() - at).coerceAtLeast(0L) + } + + /** + * 写一条阶段耗时:`FETCH-TIME 编号=… 阶段=… 自点歌=xxms 详情=…`。 + * + * 没有点歌记录时(例如后台自动重试)只记阶段名,不编造耗时。 + */ + fun stage(id: String?, stage: String, detail: String = "") { + val elapsed = elapsedMs(id) + val cost = if (elapsed == null) "自点歌=?" else "自点歌=${elapsed}ms" + val tail = if (detail.isEmpty()) "" else " 详情=$detail" + sink("FETCH-TIME 编号=${normalize(id).orEmpty()} 阶段=$stage $cost$tail") + } + + /** 该编号是否有进行中的记录(单测用)。 */ + fun hasOrder(id: String?): Boolean = synchronized(lock) { orderAtById.containsKey(normalize(id).orEmpty()) } + + /** 当前记录条数(单测用:核对有界)。 */ + fun size(): Int = synchronized(lock) { orderAtById.size } + + fun clear() { + synchronized(lock) { orderAtById.clear() } + } + + private fun normalize(id: String?): String? = id?.trim()?.takeIf { it.isNotEmpty() } +} + +/** + * 全局实例(生产用)。 + * + * `sink` 可被替换(单测里换成"收集到 list"),默认就是 [MiliLog.local]。 + */ +object SongFetchTrace { + @Volatile + private var trace = FetchTrace() + + /** 记录点歌起点(在点歌/下载入口调用,UI 线程安全:只写一个 map)。 */ + @JvmStatic + fun beginOrder(id: String?) = trace.beginOrder(id) + + /** 记一条阶段耗时(只写本地日志)。 */ + @JvmStatic + fun stage(id: String?, stage: String, detail: String = "") = trace.stage(id, stage, detail) + + @JvmStatic + fun elapsedMs(id: String?): Long? = id?.let { trace.elapsedMs(it) } + + /** 单测用:换掉实现(返回旧的,便于 after 恢复)。 */ + @JvmStatic + fun swapForTest(replacement: FetchTrace): FetchTrace { + val old = trace + trace = replacement + return old + } +} diff --git a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt index 09f6d36..c99f02a 100644 --- a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt +++ b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt @@ -154,6 +154,9 @@ object SongOkDownloadManager { @JvmStatic fun download(song: Song, callback: DownloadCallback?) { + // 阶段计时的起点 = 用户点歌这一刻(本方法由 MainActivity 在**主线程**同步调用)。 + val traceId = musicNoOf(song) + SongFetchTrace.beginOrder(traceId) if (isDownloaded(song)) { val localFile = getLocalFile(song) Log.i(TAG, "Using verified local file: ${localFile.absolutePath}, ${localFile.length()} bytes") @@ -167,11 +170,15 @@ object SongOkDownloadManager { } io.execute { try { + // 阶段日志:这一段量的是"点歌 → 取址任务真的开始跑", + // 包含主线程剩余处理 + **io 池排队**(4 个线程会被下载/取址任务占住)。 + SongFetchTrace.stage(traceId, "点歌→取址任务开始(含 io 池排队)") var url = buildDownloadUrl(song) if (url.isEmpty()) { // 首次下载偶发解析不到地址(JS 桥冷启动/节点瞬时抖动)。短暂退避后重试一次, // 复现“手动重试即恢复”的体验,避免第一首歌一次性报失败。 Log.w(TAG, "First URL resolve failed for ${song.title}, retrying once") + SongFetchTrace.stage(traceId, "首次取址失败→1200ms 后重试") Thread.sleep(1_200) url = buildDownloadUrl(song, forceRefresh = true) } @@ -250,7 +257,7 @@ object SongOkDownloadManager { // `song.id` 在曲库条目里可能是数据库主键(非点歌编号),拿它去比对会把**正常地址误杀** // (表现:真源地址被判"文件名不符"→ 永远下不到歌)。此时传 null = 跳过文件名规则, // 仅保留广告域名/路径特征那两条判定。 - val musicNoFromFile = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() } + val musicNoFromFile = musicNoOf(song) val musicNo = musicNoFromFile ?: song.id if (!forceRefresh) { song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached -> @@ -545,4 +552,11 @@ object SongOkDownloadManager { ?: song.filename?.takeIf(String::isNotEmpty) ?: song.title.orEmpty() + /** + * 「从文件名推出的点歌编号」(`7678785.ts` / `7586669.ls` → `7678785`)。 + * 与 [buildDownloadUrl] 用的是**同一个**表达式(阶段计时与闸门必须同口径)。 + */ + private fun musicNoOf(song: Song): String? = + song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() } + } diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index 7565a26..a0158d8 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -49,6 +49,26 @@ object SongUrlResolver { /** `api.php` 配置缓存时长(原版每次启动/长时间后取一次即可)。 */ private const val CONFIG_TTL_MS = 30 * 60_000L + /** + * 上游 `/i.php` token 的**按 host 缓存**(TTL 按源站返回的 `heart_time`,见 [SourceTokenCache])。 + * + * ⚠️ 这是"点歌延迟优化"的核心之一:旧实现每首歌、每个源都无条件重打一次 `/i.php`, + * 那一次往返(弱网下还要算 DNS + TCP + TLS)全部排在 `/music/do.php` 之前。 + * 协议本身**一个字都没改**:还是同一个带签名的 GET,只是把响应按源站自报的有效期复用。 + */ + private val tokenCache = SourceTokenCache(load = { host -> loadUpstreamToken(host) }) + + /** 预热入口:把第一个源的 `/i.php` token 提前取好([SourcePreheat] 在后台调用)。 */ + fun preheatUpstreamToken(host: String): String? = + runCatching { tokenCache.tokenFor(host) } + .onFailure { + MiliLog.localWarn("PREHEAT i.php 预热异常 源=$host ${it.javaClass.simpleName}: ${it.message}") + } + .getOrNull() + + /** 清空上游 token 缓存(换设备身份 / 断网恢复后调用,强制重新取令牌)。 */ + fun clearUpstreamTokens() = tokenCache.clear() + /** * 歌曲源入口默认值:**官方源优先、自建源兜底**(2026-10-14 反转)。 * @@ -91,6 +111,9 @@ object SongUrlResolver { @Volatile private var config: SourceConfig? = null @Volatile private var configAt: Long = 0L + /** `api.php` 配置预热的单飞锁(预热与取址可能并发)。 */ + private val configLock = Any() + /** * 上报给源站的设备身份 `sn`/`mac`:**与 whoami 心跳、`i.php` 同一份** * (`DeviceIdentity` 生成一次、落盘后固定不变)。 @@ -181,41 +204,48 @@ object SongUrlResolver { } /** - * 拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。 + * **后台**拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。 * * 用途:① 日志里给出"是否启用试用/试用次数/时长/是否需要卡密"这些**结论**; * ② 按 `trial_times` 限制一次取址尝试的源数量,避免把试用额度瞬间耗尽。 + * + * ⚠️ **点歌延迟优化**:旧实现是**同步**走在取址关键路径最前面的 —— 一首歌发出的第一个请求 + * 不是 `/i.php` 而是这个 `POST api.php`(当前官方节点根本没有这个接口,白等一次完整往返)。 + * 现在它只由 [SourcePreheat] 在后台预热调用;点歌路径只用**已经拿到的**配置, + * 拿不到就按全部源尝试(与旧的负缓存兜底语义一致)。 */ - private fun ensureConfig(host: String, mac: String) { + fun preheatConfig(host: String = currentHosts().firstOrNull().orEmpty(), mac: String = this.mac) { + if (host.isEmpty()) return if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return val channel = post ?: return - val form = "action=get_config&device_id=$mac" - val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return - val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return - // ⚠️ 失败也要进负缓存:本节点(zk.ktv1.top:6677)**没有 api.php**(404), - // 不缓存的话每首歌取址前都要白请求一次,既拖慢又白耗额度。 - configAt = System.currentTimeMillis() - val status = envelope.optInt("status") - if (status !in 200..299) { - MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status(本节点无配置接口,按全部源尝试)") - return - } - val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return - val c = body.optJSONObject("config") ?: body - val times = c.optInt("trial_times", 0) - val dur = c.optInt("trial_duration", 0) - val verify = c.optBoolean("network_verification", false) - config = SourceConfig(times, dur, verify) - configAt = System.currentTimeMillis() - // 远程只留一条"结论";完整 JSON 与卡密提示只写本地。 - MiliLog.info( - "SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " + - "trial_times=$times trial_duration=${dur}min network_verification=$verify " + - "heartbeat=${c.optInt("heartbeat_interval", 0)}s", - ) - MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}") - if (verify && c.optString("dialog_title").isNotEmpty()) { - MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}") + synchronized(configLock) { + if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return + // 先占位(失败也进负缓存),避免预热重复发起 api.php。 + configAt = System.currentTimeMillis() + val form = "action=get_config&device_id=$mac" + val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return + val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val status = envelope.optInt("status") + if (status !in 200..299) { + MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status(本节点无配置接口,按全部源尝试)") + return + } + val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return + val c = body.optJSONObject("config") ?: body + val times = c.optInt("trial_times", 0) + val dur = c.optInt("trial_duration", 0) + val verify = c.optBoolean("network_verification", false) + config = SourceConfig(times, dur, verify) + // 远程只留一条"结论";完整 JSON 与卡密提示只写本地。 + MiliLog.info( + "SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " + + "trial_times=$times trial_duration=${dur}min network_verification=$verify " + + "heartbeat=${c.optInt("heartbeat_interval", 0)}s", + ) + MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}") + if (verify && c.optString("dialog_title").isNotEmpty()) { + MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}") + } } } @@ -248,10 +278,11 @@ object SongUrlResolver { sawForbidden = false val mac = this.mac // 与 whoami 心跳 / do.php 同一份设备身份 val startedAt = System.currentTimeMillis() + val urlStartedAt = SongFetchTrace.elapsedMs(musicno) val allHosts = synchronized(hostOrder) { hostOrder.toList() } - // 原版取址前会先 POST api.php 取配置;这里顺带用它限制一次取址的源数量 - // (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。 - ensureConfig(allHosts.firstOrNull().orEmpty(), mac) + // 原版取址前会先 POST api.php 取配置;这里**只用已经预热到的配置**限制一次取址的源数量 + // (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。⚠️ 取址关键路径上 + // **不再发 api.php 请求** —— 那个往返由 SourcePreheat 在后台提前做掉(见 preheatConfig)。 val maxHosts = config?.trialTimes?.takeIf { it > 0 }?.coerceAtMost(allHosts.size) ?: allHosts.size if (maxHosts < allHosts.size) { MiliLog.local("NATIVE-SONG 按试用额度限制本次取址源数=$maxHosts(共 ${allHosts.size} 个)") @@ -263,21 +294,33 @@ object SongUrlResolver { var adSample = "" for ((index, host) in hosts.withIndex()) { - val token = runCatching { fetchToken(channel, host, mac) }.getOrElse { + val tokenStartedAt = System.currentTimeMillis() + if (index == 0) { + // 阶段日志:原生路径上"文档里说的取址请求"就是这个 /i.php(命中缓存时为 0ms)。 + SongFetchTrace.stage(musicno, "原生取址开始(即将发 i.php)", "源=$host 点歌到此=${urlStartedAt ?: -1}ms") + } + val token = runCatching { fetchToken(host) }.getOrElse { MiliLog.localWarn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") null } + SongFetchTrace.stage( + musicno, "i.php", "源=$host 本次=${System.currentTimeMillis() - tokenStartedAt}ms 命中=${token != null}", + ) if (token.isNullOrEmpty()) { lastDetail = "token-fail" otherFailCount++ demote(host) continue } + val doStartedAt = System.currentTimeMillis() val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) } .getOrElse { MiliLog.localWarn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") null } + SongFetchTrace.stage( + musicno, "do.php", "源=$host 本次=${System.currentTimeMillis() - doStartedAt}ms", + ) if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) { promote(host) lastHost = host @@ -336,12 +379,28 @@ object SongUrlResolver { // ─── 协议实现 ─────────────────────────────────────── - private fun fetchToken(channel: (String, String?) -> String?, host: String, mac: String): String? { + /** + * 取当前源的 `/i.php` token —— **走按 host 的缓存 + 单飞**([SourceTokenCache])。 + * + * 缓存 TTL 由源站自己在响应里给的 `heart_time` 决定(实测 5400 秒 = 90 分钟; + * 取不到就用安全默认 90 分钟)。旧实现每首歌、每个源都重打一次 `/i.php`, + * 那一次往返全部排在 `/music/do.php` 之前 —— 正是"点歌后要等几秒才发出取址请求"的 + * 组成部分之一。协议本身没有改动(同一个签名 GET,只是复用响应)。 + */ + private fun fetchToken(host: String): String? = tokenCache.tokenFor(host) + + /** + * 真的去请求 `/i.php`(只在缓存未命中时被 [SourceTokenCache] 调用,且**同一 host 单飞**)。 + * + * ⚠️ 参数/签名与官方源**逐字节对齐**:不带 App 令牌(服务端把 `/i.php` 列在 + * LEGACY_CHECK_PATHS,"带了就校验" —— 我们没带,老形态完全不变)。 + */ + private fun loadUpstreamToken(host: String): UpstreamToken? { + val channel = fetch ?: return null + val mac = this.mac 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) - // ⚠️ `/i.php` 与官方源**逐字节对齐**:不带 App 令牌(服务端把它列为 LEGACY_CHECK_PATHS, - // "带了就校验" —— 我们没带,老形态完全不变;参数与签名一个字都没改)。 val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null val tokenCode = json.optInt("code") if (tokenCode == 403) sawForbidden = true // 业务码 403 = 风控(见 do.php 同处注释) @@ -349,7 +408,12 @@ object SongUrlResolver { MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}") return null } - return json.optString("token").takeIf { it.isNotEmpty() } + val token = json.optString("token").takeIf { it.isNotEmpty() } ?: return null + // 源站自报的有效时长(秒):heart_time(实测 5400)。App 侧以前完全没读它 —— + // 现在用它当缓存 TTL,语义与源站一致;字段缺失/异常时由 SourceTokenCache 兜底。 + val heartTimeSec = json.optLong("heart_time", -1L).takeIf { it > 0L } + MiliLog.local("NATIVE-SONG i.php token 已缓存 源=$host heart_time=${heartTimeSec ?: "?"}s") + return UpstreamToken(token, heartTimeSec) } private fun fetchSongUrl( @@ -371,7 +435,18 @@ object SongUrlResolver { // 取到就带 `X-App-Token`;官方源返回 404/405 会被记住"不支持令牌",之后不再尝试、也不带该头 // (避免每首歌白多一次请求)。带令牌被拒(401 + 令牌无效或已过期)→ 清缓存重取, // **只重试一次** —— 全部逻辑见 [AppTokenSession](纯 JVM,有单测)。 - val resp = AppTokenClient.withToken(host) { appToken -> callEnvelope(channel, url, appToken) } + // + // ⚠️ 点歌延迟优化:这里**显式先取一次**令牌,只为把"取令牌到底花了多久"量出来 + // (阶段日志 app-token)。命中缓存时它是纯内存查表(~0ms);真正发请求只发生在 + // 预热没跑到 / 缓存过期时。取到的值与紧接着 [AppTokenClient.withToken] 里的那次 + // 完全一样(同一个缓存),因此**请求行为没有任何变化**。 + val appTokenAt = System.currentTimeMillis() + val appToken = AppTokenClient.tokenFor(host) + SongFetchTrace.stage( + musicno, "app-token", + "源=$host 本次=${System.currentTimeMillis() - appTokenAt}ms 有令牌=${appToken != null}", + ) + val resp = AppTokenClient.withToken(host) { appTokenValue -> callEnvelope(channel, url, appTokenValue) } if (resp.status !in 200..299) return null // callEnvelope 已记日志(含 401 令牌被拒) val body = resp.body if (body.isBlank()) return null diff --git a/app/src/main/java/com/local/ktv/SourcePreheat.kt b/app/src/main/java/com/local/ktv/SourcePreheat.kt new file mode 100644 index 0000000..2a09683 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SourcePreheat.kt @@ -0,0 +1,99 @@ +package com.local.ktv + +import java.net.InetSocketAddress +import java.net.Socket +import java.util.concurrent.Executors +import javax.net.ssl.SSLSocket + +/** + * 取址预热的 **Android 侧接线**(纯逻辑在 [Preheat])。 + * + * ## 触发时机([kick] 的 reason) + * | 时机 | 位置 | 为什么 | + * |---|---|---| + * | App 启动 | `MainActivity.onCreate`([SongApiClient.init] 之后) | 用户第一次点歌前就把令牌/连接热好 | + * | JS 桥就绪 | [SongApiClient.init] 的回调(`ok == true`) | 状态跃迁时补一次 **JS 会话**预热 | + * | 进入点歌/歌曲列表 | `MainActivity` 的浏览页加载入口 | 用户已经在挑歌了,马上就要点 | + * | 回到前台 | `MainActivity.onResume` | 长时间挂后台后连接/令牌可能已经凉了 | + * | 网络恢复 | `MainActivity.refreshNetworkState` | 换了出口 IP,旧令牌/连接全废 | + * + * ## 硬约束 + * 1. **不阻塞 UI**:[kick] 只往专用守护线程池丢任务(**不占主线程、也不占下载用的 io 池**—— + * io 池的 4 个线程会被几十秒的下载任务占满,预热丢进去等于没预热); + * 2. **失败只写本地日志**:任何一步异常都只记 `MiliLog.localWarn`,绝不影响点歌/播放; + * 3. **节流 + 单飞**:见 [Preheat](同一 reason 20 秒内不重复、同一时刻只跑一轮); + * 裸传输预热再按 host 加一层 60 秒节流(`DNS/TCP/TLS` 握手没必要每轮都做)。 + */ +object SourcePreheat { + + /** 裸传输预热的每 host 节流窗口(DNS 缓存/TLS 会话票据的有效期远长于此)。 */ + private const val TRANSPORT_INTERVAL_MS = 60_000L + + /** 裸连接超时(预热是尽力而为,绝不能长时间占着线程)。 */ + private const val TRANSPORT_TIMEOUT_MS = 3_000 + + private val executor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "ktv-preheat").apply { isDaemon = true } + } + + private val transportLock = Any() + private val lastTransportAt = HashMap() + + private val preheat = Preheat( + hosts = { SongUrlResolver.currentHosts() }, + runOn = { task -> runCatching { executor.execute(task) } }, + warmJsSession = { hosts -> SongApiClient.warmUpJsSession(hosts) }, + warmUpstreamToken = { host -> SongApiClient.warmUpstreamToken(host) }, + warmTransport = { host -> warmTransport(host) }, + warmAppToken = { host -> AppTokenClient.tokenFor(host) }, + warmSourceConfig = { SongApiClient.warmSourceConfig() }, + ) + + /** 触发一轮预热(**非阻塞**,UI 线程可放心调用)。 */ + @JvmStatic + fun kick(reason: String) { + runCatching { preheat.kick(reason) } + .onFailure { MiliLog.localWarn("PREHEAT 触发失败 reason=$reason ${it.javaClass.simpleName}: ${it.message}") } + } + + /** 是否有预热在跑(自检/日志用)。 */ + @JvmStatic + fun isRunning(): Boolean = runCatching { preheat.isRunning() }.getOrDefault(false) + + /** + * DNS + TCP(HTTPS 再握手)预热。 + * + * 为什么值得做:取址用的是**干净请求**([KtvJsBridge.rawCleanGet] 的裸 Socket), + * 每次请求都新建连接、不做 keep-alive,因此每个源的第一首歌都要付 + * 「DNS 解析 + TCP 三次握手(+ TLS 握手)」。这一步与"点的是哪首歌"无关,可以提前做。 + * + * ⚠️ HTTPS 必须走 `TlsCompat.sslSocketFactory()` + `createSocket(plain, host, port, true)` + * (先连接再按主机名包装),与取址通道同一口径:老设备(API<24)的系统信任库没有 + * ISRG 新根,用默认工厂会握手失败 —— 预热带不上任何好处,还可能误判。 + */ + private fun warmTransport(host: String) { + val now = System.currentTimeMillis() + synchronized(transportLock) { + if (now - (lastTransportAt[host] ?: 0L) < TRANSPORT_INTERVAL_MS) return + lastTransportAt[host] = now + } + val uri = runCatching { java.net.URI(host) }.getOrNull() ?: return + val name = uri.host ?: return + val isHttps = uri.scheme.equals("https", ignoreCase = true) + val port = if (uri.port > 0) uri.port else if (isHttps) 443 else 80 + // DNS:显式解析一次,把结果放进 JVM/系统解析器缓存(下一次请求省掉一次查询)。 + java.net.InetAddress.getByName(name) + val plain = Socket() + try { + plain.connect(InetSocketAddress(name, port), TRANSPORT_TIMEOUT_MS) + if (isHttps) { + (TlsCompat.sslSocketFactory().createSocket(plain, name, port, true) as SSLSocket).apply { + startHandshake() + close() + } + } + } finally { + runCatching { plain.close() } + } + } +} diff --git a/app/src/main/java/com/local/ktv/SourceTokenCache.kt b/app/src/main/java/com/local/ktv/SourceTokenCache.kt new file mode 100644 index 0000000..c89a9a7 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SourceTokenCache.kt @@ -0,0 +1,149 @@ +package com.local.ktv + +/** + * 上游源站 `GET /i.php` 的 token 缓存(**纯 JVM**,不碰 Android API → 可跑纯 JVM 单测)。 + * + * ## 为什么需要它 + * `SongUrlResolver`(原生兜底取址)原先**每首歌、每个源**都重新打一次 `/i.php`: + * `resolveSourced()` 的循环里无条件调 `fetchToken()`,没有任何缓存。多源 + 弱网时, + * 每首歌都要多付一次「DNS + TCP + 请求 + 响应」的往返,全部排在 `/music/do.php` + * (真正取地址的那个请求)之前 —— 用户感知就是"点了歌要等几秒才发出取址请求"。 + * + * ## TTL 按源站语义取 + * `/i.php` 的成功响应里带 `heart_time`(实测 `5400` 秒 = 90 分钟,见 CHANGELOG V1.5.5 的 + * 逐字节响应记录)。这就是源站自己给出的"心跳/有效期"口径,因此: + * * 拿得到 `heart_time` → 按它缓存(夹在 `[MIN_TTL_SEC, MAX_TTL_SEC]` 之间,防止上游给 + * 0 / 异常值把缓存变成"永久"或"立刻过期"); + * * 拿不到 → 用安全默认 [DEFAULT_HEART_TIME_SEC](90 分钟)。 + * + * ⚠️ **协议一个字都没改**:只是把同一个 `GET /i.php?...&sign=...` 的响应复用起来; + * 缓存过期后照旧重新请求。 + * + * ## 并发(同一时刻只发一次) + * 取令牌按 **host 单飞**:同一个 host 上并发的调用只会让**第一个**真正发请求,其余等它的结果 + * (不会重复打 `/i.php`)。不同 host 之间**互不阻塞**(各自一把门闩,网络 I/O 不在全局锁里)—— + * 这一条很重要:自建源 `/token` 曾经在全局锁里做网络 I/O,一个慢源会把另一个源的取令牌一起卡住。 + * + * 失败(网络异常 / 非 2xx / 解析不出 token)会写一个**短冷却**,避免每首歌都白打一次。 + */ +class SourceTokenCache( + /** 真正去取 token:返回 null 表示这次失败(网络/非 2xx/没有 token 字段)。 */ + private val load: (String) -> UpstreamToken?, + private val clock: () -> Long = { System.currentTimeMillis() }, +) { + + private class Entry(@JvmField val token: String, @JvmField val expiresAtMs: Long) + + private val lock = Any() + private val tokens = HashMap() + /** 每个 host 一把门闩:只用于"同一 host 同时只发一次 /i.php",不跨 host 阻塞。 */ + private val gates = HashMap() + /** 失败后的短冷却(到该时刻为止不再尝试)。 */ + private val retryAt = HashMap() + /** 单测用:每个 host 实际发过几次 `/i.php`。 */ + private val fetches = HashMap() + + /** + * 取当前源的 token(可能直接用缓存)。 + * + * @return token;null = 这次用不了(失败 / 冷却中)。 + */ + fun tokenFor(host: String): String? { + val key = normalize(host) ?: return null + freshToken(key)?.let { return it } + val gate = synchronized(lock) { gates.getOrPut(key) { Any() } } + synchronized(gate) { + // 双检:等门闩的这段时间里,别人可能已经把 token 取回来了。 + freshToken(key)?.let { return it } + val now = clock() + if (now < (retryAt[key] ?: 0L)) return null + synchronized(lock) { fetches[key] = (fetches[key] ?: 0) + 1 } + val loaded = runCatching { load(host) }.getOrNull() + if (loaded == null || loaded.token.isEmpty()) { + synchronized(lock) { retryAt[key] = clock() + FAILURE_BACKOFF_MS } + return null + } + val ttlMs = ttlMs(loaded.heartTimeSec) + synchronized(lock) { + tokens[key] = Entry(loaded.token, clock() + ttlMs) + retryAt.remove(key) + } + return loaded.token + } + } + + /** 主动放进缓存(预热用;`ttlMs` 为空时按 [DEFAULT_HEART_TIME_SEC])。 */ + fun put(host: String, token: String, heartTimeSec: Long? = null) { + val key = normalize(host) ?: return + if (token.isEmpty()) return + synchronized(lock) { + tokens[key] = Entry(token, clock() + ttlMs(heartTimeSec)) + retryAt.remove(key) + } + } + + /** 清掉某源的缓存(源站 403/风控后强制重取时用)。 */ + fun invalidate(host: String) { + val key = normalize(host) ?: return + synchronized(lock) { + tokens.remove(key) + retryAt.remove(key) + } + } + + fun clear() { + synchronized(lock) { + tokens.clear() + retryAt.clear() + } + } + + /** 当前缓存的 token 是否还没过期(单测/自检用)。 */ + fun hasFreshToken(host: String): Boolean = freshToken(normalize(host).orEmpty()) != null + + /** 该 host 实际发过几次 `/i.php`(单测用:核对"命中缓存不重复请求")。 */ + fun fetchCount(host: String): Int = synchronized(lock) { fetches[normalize(host).orEmpty()] ?: 0 } + + private fun freshToken(key: String): String? { + if (key.isEmpty()) return null + synchronized(lock) { + val entry = tokens[key] ?: return null + if (clock() < entry.expiresAtMs) return entry.token + tokens.remove(key) + return null + } + } + + private fun ttlMs(heartTimeSec: Long?): Long { + val seconds = (heartTimeSec ?: DEFAULT_HEART_TIME_SEC).coerceIn(MIN_TTL_SEC, MAX_TTL_SEC) + return seconds * 1000L + } + + private fun normalize(host: String?): String? = + host?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() } + + companion object { + /** 上游没给 `heart_time` 时的安全默认:90 分钟(实测源站给的就是 5400 秒)。 */ + const val DEFAULT_HEART_TIME_SEC = 90L * 60L + + /** TTL 下界:上游给 0/异常小值时至少缓存 1 分钟,避免退化成"每首歌都打 /i.php"。 */ + const val MIN_TTL_SEC = 60L + + /** TTL 上界:12 小时(防止上游给出荒谬的大值把缓存变成"永久")。 */ + const val MAX_TTL_SEC = 12L * 60L * 60L + + /** 取 token 失败后的短冷却。 */ + const val FAILURE_BACKOFF_MS = 30_000L + } +} + +/** + * 一次成功的 `/i.php` 结果:**token** + 源站自报的有效时长 `heart_time`(秒,可能没有)。 + * + * 用**纯数据类**而不是 `org.json.JSONObject`:单测跑在纯 JVM 上,Android 的 `org.json` 是空壳 + * (见 [DeviceIdentity] 类头注释),解析必须留在调用方([SongUrlResolver])。 + */ +class UpstreamToken @JvmOverloads constructor( + @JvmField val token: String, + @JvmField val heartTimeSec: Long? = null, +) diff --git a/app/src/test/java/com/local/ktv/AppTokenTest.java b/app/src/test/java/com/local/ktv/AppTokenTest.java index 29e0e9f..a5da862 100644 --- a/app/src/test/java/com/local/ktv/AppTokenTest.java +++ b/app/src/test/java/com/local/ktv/AppTokenTest.java @@ -213,6 +213,66 @@ public class AppTokenTest { assertEquals("判定为令牌被拒 → 清缓存并重取一次", 2, server.calls()); } + // ── 并发:同一 host 同时只发一次;不同 host 互不阻塞(2026-10-15 点歌延迟优化)── + + @Test + public void concurrentTokenRequestsForSameHostHitServerOnce() throws Exception { + final int threads = 8; + final java.util.concurrent.CountDownLatch start = new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.CountDownLatch done = new java.util.concurrent.CountDownLatch(threads); + final java.util.List results = java.util.Collections.synchronizedList(new java.util.ArrayList()); + final java.util.concurrent.atomic.AtomicInteger slowLoads = new java.util.concurrent.atomic.AtomicInteger(); + // 让"网络请求"慢一点:并发调用者必须等第一个人的结果,而不是各自再发一次。 + AppTokenSession session = new AppTokenSession(url -> { + slowLoads.incrementAndGet(); + try { Thread.sleep(120); } catch (InterruptedException ignored) {} + return new TokenHttp(200, "{\"ok\":true,\"token\":\"tk-1\",\"expires_in\":600}"); + }); + + for (int i = 0; i < threads; i++) { + Thread thread = new Thread(() -> { + try { start.await(); } catch (InterruptedException ignored) {} + results.add("token:" + session.tokenFor("https://mdmuse.kasugano.cn")); + done.countDown(); + }); + thread.start(); + } + start.countDown(); + assertTrue("并发取令牌应在 5 秒内完成", done.await(5, java.util.concurrent.TimeUnit.SECONDS)); + + assertEquals("同一 host 并发只能发一次 /token", 1, slowLoads.get()); + int tokenCount = 0; + for (String value : results) if (value.equals("token:tk-1")) tokenCount++; + assertEquals("所有并发调用者拿到同一个令牌", threads, tokenCount); + } + + @Test + public void slowHostDoesNotBlockAnotherHost() throws Exception { + final java.util.concurrent.CountDownLatch gate = new java.util.concurrent.CountDownLatch(1); + final java.util.concurrent.atomic.AtomicInteger loads = new java.util.concurrent.atomic.AtomicInteger(); + AppTokenSession session = new AppTokenSession(url -> { + loads.incrementAndGet(); + if (url.startsWith("https://slow.example")) { + try { gate.await(5, java.util.concurrent.TimeUnit.SECONDS); } catch (InterruptedException ignored) {} + return new TokenHttp(200, "{\"ok\":true,\"token\":\"slow\",\"expires_in\":600}"); + } + return new TokenHttp(200, "{\"ok\":true,\"token\":\"fast\",\"expires_in\":600}"); + }); + + Thread slow = new Thread(() -> session.tokenFor("https://slow.example")); + slow.start(); + Thread.sleep(80); // 让慢源确实进入"网络请求"里 + + long startedAt = System.nanoTime(); + assertEquals("fast", session.tokenFor("https://fast.example")); + long elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L; + assertTrue("不同 host 之间不得互相排队(实测 " + elapsedMs + "ms)", elapsedMs < 500); + + gate.countDown(); + slow.join(5_000); + assertEquals(2, loads.get()); + } + // ── 解析工具 ── @Test diff --git a/app/src/test/java/com/local/ktv/FetchTraceTest.kt b/app/src/test/java/com/local/ktv/FetchTraceTest.kt new file mode 100644 index 0000000..5bc7425 --- /dev/null +++ b/app/src/test/java/com/local/ktv/FetchTraceTest.kt @@ -0,0 +1,115 @@ +package com.local.ktv + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.atomic.AtomicLong + +/** + * 取址分阶段耗时([FetchTrace])单测。 + * + * 关心三件事:① 记的是"点歌 → 该阶段"的真实耗时;② **没有点歌记录时不编造数字**; + * ③ 有界(点歌编号是外部输入,不能无限攒)。 + */ +class FetchTraceTest { + + private class Recorder { + val now = AtomicLong(1_700_000_000_000L) + val lines = mutableListOf() + fun trace(maxEntries: Int = 32): FetchTrace = FetchTrace(now::get, { lines += it }, maxEntries) + } + + @Test + fun stageRecordsElapsedSinceOrder() { + val r = Recorder() + val trace = r.trace() + trace.beginOrder("7303235") + r.now.addAndGet(1_234L) + trace.stage("7303235", "i.php", "源=http://zk.ktv1.top:6677") + + assertEquals(1, r.lines.size) + val line = r.lines.first() + assertTrue("阶段行必须是 FETCH-TIME 前缀(便于现场 grep)", line.startsWith("FETCH-TIME ")) + assertTrue("必须带编号", line.contains("编号=7303235")) + assertTrue("必须带阶段名", line.contains("阶段=i.php")) + assertTrue("必须带'自点歌'耗时", line.contains("自点歌=1234ms")) + assertTrue("详情要一起带上", line.contains("源=http://zk.ktv1.top:6677")) + } + + @Test + fun elapsedIsMeasuredFromTheLatestOrder() { + val r = Recorder() + val trace = r.trace() + trace.beginOrder("7303235") + r.now.addAndGet(5_000L) + assertEquals(5_000L, trace.elapsedMs("7303235")) + + // 重新点同一首歌:基准回到这一次(否则重试点歌会报出累计时间)。 + trace.beginOrder("7303235") + r.now.addAndGet(100L) + assertEquals(100L, trace.elapsedMs("7303235")) + } + + @Test + fun withoutOrderItDoesNotInventNumbers() { + val r = Recorder() + val trace = r.trace() + trace.stage("7303235", "闸门(等 JS 桥就绪)") + assertNull(trace.elapsedMs("7303235")) + assertEquals(1, r.lines.size) + assertTrue("没有点歌记录时不能编造耗时", r.lines.first().contains("自点歌=?")) + assertFalse(r.lines.first().contains("自点歌=0ms")) + } + + @Test + fun blankIdsDoNotCreateOrderEntries() { + val r = Recorder() + val trace = r.trace() + trace.beginOrder(" ") + assertFalse(trace.hasOrder("")) + assertNull(trace.elapsedMs("")) + // 空编号的阶段日志仍然要写(现场可能是后台重试),但**不许**编造耗时。 + trace.stage(null, "x") + assertEquals(1, r.lines.size) + assertTrue(r.lines.first().contains("自点歌=?")) + } + + @Test + fun entriesAreBounded() { + val r = Recorder() + val trace = r.trace(maxEntries = 4) + repeat(10) { trace.beginOrder("song-$it") } + assertEquals("必须丢最旧的,绝不无限增长", 4, trace.size()) + assertTrue(trace.hasOrder("song-9")) + assertFalse(trace.hasOrder("song-0")) + } + + @Test + fun clearDropsEverything() { + val r = Recorder() + val trace = r.trace() + trace.beginOrder("a") + trace.clear() + assertEquals(0, trace.size()) + assertNull(trace.elapsedMs("a")) + } + + @Test + fun globalEntryPointDelegatesAndGoesToLocalLog() { + // 生产入口:SongFetchTrace 的默认 sink 是 MiliLog.local(单测里 MiliLog 未 init → 空操作, + // 因此这里只核对"能调用、不抛异常、可替换")。 + val r = Recorder() + val old = SongFetchTrace.swapForTest(r.trace()) + try { + SongFetchTrace.beginOrder("7678785") + r.now.addAndGet(250L) + SongFetchTrace.stage("7678785", "点歌→取址任务开始(含 io 池排队)") + assertEquals(250L, SongFetchTrace.elapsedMs("7678785")) + assertTrue(r.lines.first().contains("阶段=点歌→取址任务开始")) + } finally { + SongFetchTrace.swapForTest(old) + } + } +} diff --git a/app/src/test/java/com/local/ktv/PreheatTest.kt b/app/src/test/java/com/local/ktv/PreheatTest.kt new file mode 100644 index 0000000..177b5b3 --- /dev/null +++ b/app/src/test/java/com/local/ktv/PreheatTest.kt @@ -0,0 +1,309 @@ +package com.local.ktv + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +/** + * 取址预热([Preheat] 纯逻辑)单测。 + * + * 需求点名的四件事: + * ① **预热不阻塞**调用方(UI 线程); + * ② **同一时刻只跑一轮**(单飞,避免对源站密集探测); + * ③ **失败只写本地日志**(绝不外抛、绝不影响点歌); + * ④ 步骤顺序:会发 HTTP 的那步优先(顺带热好 DNS/TCP/TLS),它没发请求时才补原生侧/裸传输。 + */ +class PreheatTest { + + private class Fixture { + val logs: MutableList = Collections.synchronizedList(mutableListOf()) + val calls: MutableList = Collections.synchronizedList(mutableListOf()) + val now = AtomicLong(1_700_000_000_000L) + + fun preheat( + hosts: List, + executor: (Runnable) -> Unit, + jsWarms: Boolean, + throwEverything: Boolean, + ): Preheat = Preheat( + hosts = { + if (throwEverything) throw IllegalStateException("hosts() 挂了") + hosts + }, + runOn = executor, + warmJsSession = { h -> + calls += "js:" + h.joinToString("|") + if (throwEverything) throw IllegalStateException("js 预热挂了") + jsWarms + }, + warmUpstreamToken = { h -> + calls += "upstream:$h" + if (throwEverything) throw IllegalStateException("i.php 预热挂了") + }, + warmTransport = { h -> + calls += "transport:$h" + if (throwEverything) throw IllegalStateException("传输预热挂了") + }, + warmAppToken = { h -> + calls += "token:$h" + if (throwEverything) throw IllegalStateException("/token 预热挂了") + }, + warmSourceConfig = { + calls += "config" + if (throwEverything) throw IllegalStateException("api.php 预热挂了") + }, + clock = now::get, + log = { logs += it }, + ) + } + + // ── ① 预热不阻塞 ── + + @Test + fun kickNeverBlocksCaller() { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + // 预热第一步就卡住:调用方必须**立刻**返回,否则 UI 线程会被拖住。 + val preheat = Preheat( + hosts = { listOf("http://a.example") }, + runOn = { runnable -> Thread(runnable, "preheat-test").start() }, + warmJsSession = { + entered.countDown() + release.await(5, TimeUnit.SECONDS) + true + }, + warmUpstreamToken = {}, warmTransport = {}, warmAppToken = {}, warmSourceConfig = {}, + log = {}, + ) + + val startedAt = System.nanoTime() + val kicked = preheat.kick(Preheat.REASON_STARTUP) + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L + + assertTrue("kick 应把任务交给后台", kicked) + assertTrue("kick 必须立即返回(实测 ${elapsedMs}ms)", elapsedMs < 300) + assertTrue("预热确实在后台跑", entered.await(3, TimeUnit.SECONDS)) + assertTrue("后台在跑时 isRunning 应为 true", preheat.isRunning()) + + release.countDown() + val deadline = System.currentTimeMillis() + 5_000L + while (preheat.isRunning() && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertFalse("跑完应复位(否则后续预热永远被单飞挡住)", preheat.isRunning()) + } + + // ── ② 同一时刻只发一次(单飞) ── + + @Test + fun kickRunsAtMostOneRoundAtATime() { + val release = CountDownLatch(1) + val entered = CountDownLatch(1) + val preheat = Preheat( + hosts = { listOf("http://a.example") }, + runOn = { runnable -> Thread(runnable, "preheat-test").start() }, + warmJsSession = { + entered.countDown() + release.await(5, TimeUnit.SECONDS) + true + }, + warmUpstreamToken = {}, warmTransport = {}, warmAppToken = {}, warmSourceConfig = {}, + log = {}, + ) + + assertTrue(preheat.kick(Preheat.REASON_STARTUP)) + // 换一个 reason 也一样:单飞是**全局**的,否则并发预热会对源站叠加请求。 + assertFalse("已有预热在跑时,第二次 kick 必须被拒", preheat.kick(Preheat.REASON_RESUME)) + assertFalse(preheat.kick(Preheat.REASON_SONG_LIST)) + assertTrue("后台确实开始跑了", entered.await(3, TimeUnit.SECONDS)) + assertEquals("真的只跑了一轮", 1, preheat.roundCount()) + + release.countDown() + val deadline = System.currentTimeMillis() + 5_000L + while (preheat.isRunning() && System.currentTimeMillis() < deadline) Thread.sleep(20) + assertEquals(1, preheat.roundCount()) + } + + // ── 同一 reason 节流 / 不同 reason 各自计时 ── + + @Test + fun sameReasonIsThrottledButDifferentReasonIsNot() { + val f = Fixture() + // 同步执行器:kick 里就把这一轮跑完,便于用假时钟断言节流。 + val preheat = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = true, throwEverything = false) + + assertTrue(preheat.kick(Preheat.REASON_STARTUP)) + assertEquals(1, preheat.roundCount()) + assertFalse("同一 reason 在节流窗口内不得重复", preheat.kick(Preheat.REASON_STARTUP)) + assertEquals(1, preheat.roundCount()) + + // 另一个触发原因有自己的桶(进入点歌列表 / 回前台是不同事件)。 + assertTrue(preheat.kick(Preheat.REASON_SONG_LIST)) + assertEquals(2, preheat.roundCount()) + + // 节流窗口过去后,同一 reason 可以再来一轮。 + f.now.addAndGet(Preheat.DEFAULT_MIN_INTERVAL_MS + 1) + assertTrue(preheat.kick(Preheat.REASON_STARTUP)) + assertEquals(3, preheat.roundCount()) + } + + // ── ③ 失败只写本地日志、绝不影响点歌 ── + + @Test + fun failuresOnlyGoToLogAndNeverThrow() { + val f = Fixture() + // 每一步(JS 会话 / i.php / 传输 / App 令牌 / api.php)都抛异常:kick 绝不能外抛。 + val preheat = f.preheat( + listOf("http://a.example", "https://b.example"), + { it.run() }, jsWarms = false, throwEverything = true, + ) + val kicked = preheat.kick(Preheat.REASON_STARTUP) + assertTrue("调度本身是成功的(失败发生在步骤里)", kicked) + assertFalse("跑完必须复位 running", preheat.isRunning()) + // hosts() 也抛异常时同样只能记日志。 + val hostsThrow = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = false, throwEverything = true) + f.now.addAndGet(Preheat.DEFAULT_MIN_INTERVAL_MS + 1) + assertTrue(hostsThrow.kick(Preheat.REASON_RESUME)) + + val joined = f.logs.joinToString("\n") + assertTrue("失败必须留下本地日志(含 hosts() 异常那一轮)", joined.contains("PREHEAT 跳过")) + assertFalse("running 不得残留", preheat.isRunning() || hostsThrow.isRunning()) + } + + @Test + fun stepFailuresAreLoggedWithStepName() { + val f = Fixture() + val preheat = Preheat( + hosts = { listOf("http://a.example") }, + runOn = { it.run() }, + warmJsSession = { throw IllegalStateException("js 预热挂了") }, + warmUpstreamToken = { throw IllegalStateException("i.php 挂了") }, + warmTransport = { throw IllegalStateException("传输挂了") }, + warmAppToken = { throw IllegalStateException("token 挂了") }, + warmSourceConfig = { throw IllegalStateException("api.php 挂了") }, + clock = f.now::get, + log = { f.logs += it }, + ) + preheat.kick(Preheat.REASON_STARTUP) + + val joined = f.logs.joinToString("\n") + assertTrue(joined.contains("PREHEAT 步骤失败")) + for (step in listOf("步骤=js-session", "步骤=source-config", "步骤=upstream-token", "步骤=transport", "步骤=app-token")) { + assertTrue("日志里要能看出是哪一步失败:$step", joined.contains(step)) + } + assertFalse("失败不能影响 running 复位", preheat.isRunning()) + } + + @Test + fun failureDoesNotAffectLaterRounds() { + val f = Fixture() + val preheat = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = false, throwEverything = true) + assertTrue(preheat.kick(Preheat.REASON_STARTUP)) + // 失败一轮之后,下一次触发(窗口外)照旧能跑 —— 预热是尽力而为,不是一次性闸门。 + f.now.addAndGet(Preheat.DEFAULT_MIN_INTERVAL_MS + 1) + assertTrue(preheat.kick(Preheat.REASON_STARTUP)) + assertEquals(2, preheat.roundCount()) + assertFalse(preheat.isRunning()) + } + + @Test + fun executorRejectionIsSwallowed() { + val f = Fixture() + val preheat = Preheat( + hosts = { listOf("http://a.example") }, + runOn = { throw RejectedExecutionException("池满了") }, + warmJsSession = { true }, + warmUpstreamToken = {}, warmTransport = {}, warmAppToken = {}, warmSourceConfig = {}, + clock = f.now::get, + log = { f.logs += it }, + ) + + assertFalse("执行器拒绝时返回 false", preheat.kick(Preheat.REASON_STARTUP)) + assertTrue("必须记日志", f.logs.joinToString("\n").contains("PREHEAT 调度失败")) + assertFalse("running 必须复位,否则以后再也预热不了", preheat.isRunning()) + } + + // ── ④ 步骤顺序 / 覆盖范围 ── + + @Test + fun jsSessionWarmSkipsDuplicateUpstreamRequests() { + val f = Fixture() + val preheat = f.preheat( + listOf("http://official.example", "https://self.example"), + { it.run() }, jsWarms = true, throwEverything = false, + ) + preheat.kick(Preheat.REASON_STARTUP) + + assertFalse( + "JS 会话预热已发请求时,不应再打一遍原生侧 /i.php", + f.calls.contains("upstream:http://official.example"), + ) + assertTrue( + "但传输预热与 App 令牌仍要覆盖每个源", + f.calls.contains("transport:http://official.example") && + f.calls.contains("transport:https://self.example") && + f.calls.contains("token:https://self.example"), + ) + assertEquals("api.php 配置每轮只预热一次", 1, f.calls.count { it == "config" }) + } + + @Test + fun kotlinUpstreamTokenIsWarmedWhenJsBridgeIsNotReady() { + val f = Fixture() + val preheat = f.preheat( + listOf("http://official.example", "https://self.example"), + { it.run() }, jsWarms = false, throwEverything = false, + ) + preheat.kick(Preheat.REASON_STARTUP) + + assertTrue( + "桥不可用(老盒子)时必须热原生侧的 /i.php 缓存", + f.calls.contains("upstream:http://official.example") && + f.calls.contains("upstream:https://self.example"), + ) + } + + @Test + fun warmsAtMostThreeHostsInOrder() { + val f = Fixture() + val preheat = f.preheat( + listOf("http://h1.example", "http://h2.example", "http://h3.example", "http://h4.example"), + { it.run() }, jsWarms = false, throwEverything = false, + ) + preheat.kick(Preheat.REASON_STARTUP) + + assertTrue(f.calls.contains("upstream:http://h3.example")) + assertFalse( + "第四个源不再预热(防止源列表很长时一次打出太多请求)", + f.calls.contains("upstream:http://h4.example"), + ) + assertEquals( + "JS 会话预热拿到的是按顺序截断后的列表", + "js:http://h1.example|http://h2.example|http://h3.example", + f.calls.first(), + ) + } + + @Test + fun emptyHostListIsSkippedQuietly() { + val f = Fixture() + val preheat = f.preheat(emptyList(), { it.run() }, jsWarms = false, throwEverything = false) + assertTrue(preheat.kick(Preheat.REASON_STARTUP)) + assertTrue(f.logs.joinToString("\n").contains("没有可用源")) + assertEquals(0, f.calls.size) + } + + @Test + fun logLineTellsWhyAndHowLong() { + val f = Fixture() + val preheat = f.preheat(listOf("http://a.example"), { it.run() }, jsWarms = true, throwEverything = false) + preheat.kick(Preheat.REASON_SONG_LIST) + val joined = f.logs.joinToString("\n") + assertTrue("完成行要带触发原因与耗时", joined.contains("PREHEAT 完成 reason=song-list")) + assertTrue(joined.contains("用时=")) + } +} diff --git a/app/src/test/java/com/local/ktv/SongFetchRetryPolicyTest.kt b/app/src/test/java/com/local/ktv/SongFetchRetryPolicyTest.kt new file mode 100644 index 0000000..99d1575 --- /dev/null +++ b/app/src/test/java/com/local/ktv/SongFetchRetryPolicyTest.kt @@ -0,0 +1,98 @@ +package com.local.ktv + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 「**首次尝试零等待、退避只作用于重试**」的静态锁(`assets/mobile/ktv_api.js`)。 + * + * 为什么用文本断言:这是 JS 侧的行为,纯 JVM 单测跑不了它(Android 上由隐藏 WebView 执行)。 + * 但本轮用户报的"点歌后 3~5 秒"正是这里的一条 `await sleep(3000)`,必须在 CI 里被钉住: + * 一旦有人把退避重新挪回首次请求之前、或给终态失败(403/404/4xx)加回退避,这个测试就红。 + * + * 同一份脚本另有 Node stub 对照测量(`tmp/measure_ktv_js.js`,优化前 4064ms → 优化后 903ms)。 + */ +class SongFetchRetryPolicyTest { + + private val js: String = RepoFiles.ktvApiJs() + + /** 取址主流程里"真正发出 do.php 请求"的那一行(首次尝试)。 */ + private val firstAttemptLine = "var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);" + + @Test + fun oldThreeSecondBackoffIsGone() { + // ⚠️ 只匹配**可执行代码**:注释里会提到旧的 sleep(3000)(那是说明,不是行为)。 + val code = js.lines().filterNot { it.trim().startsWith("//") || it.trim().startsWith("*") }.joinToString("\n") + assertFalse( + "旧的固定 sleep(3000) 必须从可执行代码里彻底消失(它就是用户实测的 3 秒白等)", + code.contains("sleep(3000)"), + ) + assertTrue("退避时长必须是具名常量,便于核对与调整", js.contains("const RETRY_BACKOFF_MS =")) + } + + @Test + fun backoffIsUsedOnlyBeforeARetry() { + // 全脚本只允许一处 await sleep(...):重试之前。 + assertEquals( + "退避只允许出现在重试路径上(一处)", + 1, + Regex("await\\s+sleep\\(").findAll(js).count(), + ) + + val firstAttemptAt = js.indexOf(firstAttemptLine) + val riskControlAware = js.indexOf("retry once with same token after") + val sleepAt = js.indexOf("await sleep(RETRY_BACKOFF_MS)") + assertTrue("找不到首次尝试那一行(脚本结构变了?)", firstAttemptAt > 0) + assertTrue(firstAttemptAt < sleepAt) + assertTrue("退避必须发生在首次尝试之后", riskControlAware in 1 until sleepAt) + } + + @Test + fun terminalFailuresDoNotRetry() { + assertTrue("必须有统一的终态判定函数", js.contains("function isTerminalFailure(")) + // 4xx / 业务码 403 / 404 + val fn = js.substring(js.indexOf("function isTerminalFailure("), js.indexOf("function isTerminalFailure(") + 700) + assertTrue("HTTP 4xx 属于终态", fn.contains("status >= 400 && status < 500")) + assertTrue("业务码 403(风控真实形态)属于终态", fn.contains("code === 403")) + assertTrue("业务码 404(该源没有这首)属于终态", fn.contains("code === 404")) + + // 终态分支里不能再出现"发请求"或"sleep"。 + val branchStart = js.indexOf("else if (isTerminalFailure(data.httpStatus, data.code)) {") + assertTrue("终态分支必须存在", branchStart > 0) + val branchEnd = js.indexOf("} else {", branchStart) + assertTrue(branchEnd > branchStart) + val branch = js.substring(branchStart, branchEnd) + assertFalse("终态失败不得退避", branch.contains("sleep(")) + assertFalse("终态失败不得重试", branch.contains("_fetchSongUrl(")) + assertTrue("终态失败要明确换下一个源", branch.contains("换下一个源")) + } + + @Test + fun adAndTransientFailuresStillRetryOnce() { + val branchStart = js.indexOf("} else {", js.indexOf("else if (isTerminalFailure(data.httpStatus, data.code)) {")) + val branchEnd = js.indexOf("if (data.code === 200) {", branchStart) + val branch = js.substring(branchStart, branchEnd) + assertTrue("广告/错号资源仍要退避后重试一次", branch.contains("await sleep(RETRY_BACKOFF_MS)")) + assertEquals("只重试一次", 1, Regex("_fetchSongUrl\\(").findAll(branch).count()) + } + + @Test + fun tokenRejectionRetriesImmediatelyWithoutBackoff() { + val start = js.indexOf("if (tokenRejected) {") + val end = js.indexOf("} else if (isTerminalFailure(", start) + assertTrue(start > 0 && end > start) + val branch = js.substring(start, end) + assertTrue("令牌被拒后要立刻重试一次", branch.contains("_fetchSongUrl(")) + assertFalse("令牌被拒不该退避(那是留给广告/5xx 的)", branch.contains("sleep(")) + } + + @Test + fun stageTimingHookIsPresentAndLocalOnly() { + assertTrue("JS 侧要有 _timing() 分阶段日志", js.contains("_timing: function(stage, host)")) + assertTrue("计时日志走 android.log(→ 只写本地 ktv.log)", js.contains("global.android.log('FETCH-TIME ")) + assertTrue("首次 i.php 请求发出要能计时", js.contains("self._timing('点歌→i.php请求发出'")) + assertTrue("do.php 请求发出要能计时", js.contains("this._timing('点歌→do.php请求发出'")) + } +} diff --git a/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java b/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java index a50e43d..aed83b2 100644 --- a/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java +++ b/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java @@ -89,14 +89,17 @@ public class SourceOrderConsistencyTest { */ @Test public void bridgeApiMarkerIsConsistent() throws IOException { + // 取址协议/缓存策略一变就要提升这个数字(设备端靠它判断要不要丢弃缓存的旧脚本)。 + // 16 = 点歌延迟优化(终态失败不退避 + 分阶段耗时日志)。 + final int bridgeApi = 16; String js = RepoFiles.ktvApiJs(); - assertTrue("ktv_api.js 缺少 KTV_BRIDGE_API: 15 注释标记", - js.contains("KTV_BRIDGE_API: 15")); - assertTrue("ktv_api.js 导出的版本号应为 15", - js.contains("global.KtvBridgeApiVersion = 15")); - assertTrue("KtvJsBridge 的校验标记应为 15", - RepoFiles.ktvJsBridgeKt().contains("BRIDGE_API_MARKER = \"KTV_BRIDGE_API: 15\"")); - assertTrue("AGENTS.md 里的 KTV_BRIDGE_API 记录必须与代码一致(15)", - RepoFiles.agentsMd().contains("已升到 **15**")); + assertTrue("ktv_api.js 缺少 KTV_BRIDGE_API: " + bridgeApi + " 注释标记", + js.contains("KTV_BRIDGE_API: " + bridgeApi)); + assertTrue("ktv_api.js 导出的版本号应为 " + bridgeApi, + js.contains("global.KtvBridgeApiVersion = " + bridgeApi)); + assertTrue("KtvJsBridge 的校验标记应为 " + bridgeApi, + RepoFiles.ktvJsBridgeKt().contains("BRIDGE_API_MARKER = \"KTV_BRIDGE_API: " + bridgeApi + "\"")); + assertTrue("AGENTS.md 里的 KTV_BRIDGE_API 记录必须与代码一致(" + bridgeApi + ")", + RepoFiles.agentsMd().contains("已升到 **" + bridgeApi + "**")); } } diff --git a/app/src/test/java/com/local/ktv/SourceTokenCacheTest.kt b/app/src/test/java/com/local/ktv/SourceTokenCacheTest.kt new file mode 100644 index 0000000..3ff395c --- /dev/null +++ b/app/src/test/java/com/local/ktv/SourceTokenCacheTest.kt @@ -0,0 +1,231 @@ +package com.local.ktv + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +/** + * 上游 `/i.php` token 缓存([SourceTokenCache])单测。 + * + * 覆盖需求点名的三件事: + * ① **命中缓存不重复请求**(旧实现每首歌、每个源都重打一次 `/i.php`); + * ② TTL 按源站自己的语义(`heart_time`),拿不到用 90 分钟安全默认; + * ③ **同一 host 同时只发一次**(单飞),且**不同 host 互不阻塞**。 + */ +class SourceTokenCacheTest { + + private class FakeSource { + val now = AtomicLong(1_700_000_000_000L) + val loads = AtomicInteger(0) + val loadedHosts: MutableList = Collections.synchronizedList(mutableListOf()) + @Volatile var heartTimeSec: Long? = 5400L + @Volatile var token: String = "tok-1" + @Volatile var fail = false + /** 每次"请求"的阻塞时间(模拟一次真实往返)。 */ + @Volatile var delayMs = 0L + @Volatile var gate: CountDownLatch? = null + + fun cache(): SourceTokenCache = SourceTokenCache(load = { host -> + loads.incrementAndGet() + loadedHosts += host + gate?.await(5, TimeUnit.SECONDS) + if (delayMs > 0) Thread.sleep(delayMs) + if (fail) null else UpstreamToken(token, heartTimeSec) + }, clock = now::get) + } + + // ── ① 命中缓存不重复请求 ── + + @Test + fun cachesTokenAndDoesNotRequestAgain() { + val src = FakeSource() + val cache = src.cache() + + repeat(3) { + assertEquals("tok-1", cache.tokenFor("http://zk.ktv1.top:6677")) + } + assertEquals("三次取令牌只允许打一次 /i.php", 1, src.loads.get()) + assertTrue(cache.hasFreshToken("http://zk.ktv1.top:6677")) + } + + @Test + fun differentHostsHaveIndependentCacheEntries() { + val src = FakeSource() + val cache = src.cache() + src.token = "tok-official" + assertEquals("tok-official", cache.tokenFor("http://a.example")) + src.token = "tok-self" + assertEquals("tok-self", cache.tokenFor("https://b.example")) + // 各自缓存,重复取都命中。 + assertEquals("tok-official", cache.tokenFor("http://a.example")) + assertEquals("tok-self", cache.tokenFor("https://b.example")) + assertEquals(2, src.loads.get()) + } + + @Test + fun trailingSlashAndWhitespaceShareTheSameEntry() { + val src = FakeSource() + val cache = src.cache() + assertEquals("tok-1", cache.tokenFor("http://a.example/")) + assertEquals("tok-1", cache.tokenFor(" http://a.example ")) + assertEquals(1, src.loads.get()) + } + + // ── ② TTL 语义 ── + + @Test + fun ttlComesFromSourceHeartTime() { + val src = FakeSource() + val cache = src.cache() + src.heartTimeSec = 600L // 源站自报 10 分钟 + cache.tokenFor("http://a.example") + + src.now.addAndGet(599_000L) + assertTrue("heart_time 内必须命中缓存", cache.hasFreshToken("http://a.example")) + assertEquals(1, src.loads.get()) + + src.now.addAndGet(2_000L) + assertFalse("过了 heart_time 必须重新取", cache.hasFreshToken("http://a.example")) + cache.tokenFor("http://a.example") + assertEquals(2, src.loads.get()) + } + + @Test + fun missingHeartTimeFallsBackToNinetyMinutes() { + val src = FakeSource() + val cache = src.cache() + src.heartTimeSec = null + cache.tokenFor("http://a.example") + + src.now.addAndGet(89L * 60L * 1000L) + assertEquals("拿不到 heart_time → 安全默认 90 分钟", 1, src.loads.get()) + + src.now.addAndGet(2L * 60L * 1000L) + cache.tokenFor("http://a.example") + assertEquals(2, src.loads.get()) + } + + @Test + fun absurdHeartTimeIsClamped() { + val src = FakeSource() + val cache = src.cache() + // 上游给出 0:不能变成"立刻过期"(那就退化成每首歌都打一次 /i.php)。 + src.heartTimeSec = 0L + cache.tokenFor("http://a.example") + src.now.addAndGet(30_000L) + assertEquals("0 秒被夹到 TTL 下界(1 分钟)", 1, src.loads.get()) + } + + // ── ③ 单飞(同一 host 同时只发一次;不同 host 不互阻) ── + + @Test + fun concurrentRequestsForSameHostHitTheSourceOnce() { + val src = FakeSource() + val cache = src.cache() + src.delayMs = 120L + val threads = 8 + val start = CountDownLatch(1) + val done = CountDownLatch(threads) + val results: MutableList = Collections.synchronizedList(mutableListOf()) + + repeat(threads) { + Thread { + start.await() + results += cache.tokenFor("http://a.example") + done.countDown() + }.start() + } + start.countDown() + assertTrue("并发取令牌应在 5 秒内完成", done.await(5, TimeUnit.SECONDS)) + + assertEquals("同一 host 并发只能发一次 /i.php", 1, src.loads.get()) + assertEquals(threads, results.size) + results.forEach { assertEquals("所有并发调用者拿到同一个 token", "tok-1", it) } + } + + @Test + fun slowHostDoesNotBlockAnotherHost() { + val src = FakeSource() + val cache = src.cache() + val gate = CountDownLatch(1) + src.gate = gate + + val slow = Thread { cache.tokenFor("http://slow.example") } + slow.start() + // 慢源还卡在请求里时,另一个源必须能立刻拿到自己的令牌。 + Thread.sleep(80) + val startedAt = System.nanoTime() + src.gate = null + src.token = "tok-fast" + assertEquals("tok-fast", cache.tokenFor("http://fast.example")) + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000L + + assertTrue("不同 host 之间不得互相阻塞(实测 ${elapsedMs}ms)", elapsedMs < 500) + gate.countDown() + slow.join(5_000) + assertEquals(2, src.loads.get()) + } + + // ── 失败与失效 ── + + @Test + fun failureIsBackedOffThenRetried() { + val src = FakeSource() + val cache = src.cache() + src.fail = true + assertNull(cache.tokenFor("http://a.example")) + assertEquals(1, src.loads.get()) + + // 冷却期内不再打(否则每首歌都白打一次)。 + assertNull(cache.tokenFor("http://a.example")) + assertEquals(1, src.loads.get()) + + src.now.addAndGet(SourceTokenCache.FAILURE_BACKOFF_MS + 1) + src.fail = false + assertEquals("tok-1", cache.tokenFor("http://a.example")) + assertEquals(2, src.loads.get()) + } + + @Test + fun loaderExceptionDegradesToNullWithoutThrowing() { + val cache = SourceTokenCache(load = { throw IllegalStateException("boom") }) + assertNull("取 token 异常绝不能外抛(取址照常发,只是不带令牌)", cache.tokenFor("http://a.example")) + assertEquals(1, cache.fetchCount("http://a.example")) + } + + @Test + fun invalidateForcesRefetch() { + val src = FakeSource() + val cache = src.cache() + assertEquals("tok-1", cache.tokenFor("http://a.example")) + cache.invalidate("http://a.example") + assertFalse(cache.hasFreshToken("http://a.example")) + src.token = "tok-2" + assertEquals("tok-2", cache.tokenFor("http://a.example")) + assertEquals(2, src.loads.get()) + } + + @Test + fun putPrefillsCacheWithoutRequest() { + val src = FakeSource() + val cache = src.cache() + cache.put("http://a.example", "tok-warm", 5400L) + assertEquals("tok-warm", cache.tokenFor("http://a.example")) + assertEquals("预热写入后不得再发请求", 0, src.loads.get()) + } + + @Test + fun blankHostIsIgnored() { + val src = FakeSource() + val cache = src.cache() + assertNull(cache.tokenFor(" ")) + assertEquals(0, src.loads.get()) + } +} diff --git a/docs/app-song-source-flow.md b/docs/app-song-source-flow.md new file mode 100644 index 0000000..5ed5eaf --- /dev/null +++ b/docs/app-song-source-flow.md @@ -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["点歌入口
MainActivity.orderSong:4930 → handleSong:4979
扫码/远程 order:3573 → handleSong"] --> B["addToQueue:10382
本地已就绪? → addReadySongToQueue:10403 → play:5145"] + B --> C["下载: downloadSong:5505
→ SongOkDownloadManager.download:156(io 线程)"] + C --> D["buildDownloadUrl:248
① 校验 song.downloadUrl 缓存:256
② SongApiClient.getSongDownloadUrl():269"] + D --> E{"SongApiClient
resolveSongDownloadUrl:82"} + E -->|"主线程?"| F["fetchFromDb:146(仅 DB,禁网络)"] + E -->|"桥不可用"| G["resolveNatively:140
SongUrlResolver.resolve:218"] + E -->|"桥就绪"| H["bridge.getSongUrl:286
等 15s:102 / 185s:122"] + H -->|"JS 返回地址"| I["complete:391
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
PushDownloadClient.offer:33(fire-and-forget)"] --> L["buildDownloadUrl 最终闸门:276
AdVideoFilter.isTrustedSongUrl"] + L -->|"不可信"| M["purgeCachedSongFile:301 + 上报「地址不可信」"] + L -->|"可信"| N["startDownloadTask:317 → okdownload → finalizeDownload:403
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/`(`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 `/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 /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_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 /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` | **`_`** —— `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 /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` | **`_`**(注意与 `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 数字外推到真机**。 diff --git a/tools/measure_ktv_js.js b/tools/measure_ktv_js.js new file mode 100644 index 0000000..6d7ece0 --- /dev/null +++ b/tools/measure_ktv_js.js @@ -0,0 +1,172 @@ +/** + * JS 取址链路(maidong-ktv/app/src/main/assets/mobile/ktv_api.js)**真实脚本**的 + * 127.0.0.1 stub 对照测量工具(优化前 / 优化后跑同一份脚本)。 + * + * 为什么能这么测:ktv_api.js 走 `global.android.httpGet(url, headers, timeout)` 与 + * `global.android.appToken(host)`(Android JavascriptInterface 是**同步**调用), + * 且 `android.appToken` 背后就是 Kotlin 的 `AppTokenClient`(按 host 缓存 10 分钟)。 + * Node 里把这两个方法换成"记录时间戳 + 同步 sleep(一次往返耗时) + 按 host 缓存"的 stub, + * 就能在**不改一字**的真实脚本上量出: + * ① 点歌 → 首个请求发出; + * ② 点歌 → 拿到地址(= 用户在界面上"点了没反应"的时长); + * ③ 每首歌实际打几次 /i.php、几次 /music/do.php。 + * + * 用法:node maidong-ktv/tools/measure_ktv_js.js (仓库内) + * STUB_LATENCY_MS=200 node maidong-ktv/tools/measure_ktv_js.js + * STUB_PREHEAT=1 node maidong-ktv/tools/measure_ktv_js.js (额外模拟一轮后台预热) + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +// 直接跑(仓库内 tools/measure_ktv_js.js)与从工作区根跑(tmp/measure_ktv_js.js)都要能找到脚本。 +const JS_FILE = [ + path.resolve(__dirname, '../app/src/main/assets/mobile/ktv_api.js'), + path.resolve(__dirname, '../maidong-ktv/app/src/main/assets/mobile/ktv_api.js'), +].find((candidate) => fs.existsSync(candidate)); + +function syncSleep(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function hostOf(u) { + const m = /^[a-z]+:\/\/([^/]+)/i.exec(String(u)); + return m ? m[1] : ''; +} + +const RTT_MS = Number(process.env.STUB_LATENCY_MS || 150); +const OFFICIAL = 'http://zk.ktv1.top:6677'; +const SELF = 'https://mdmuse.kasugano.cn'; +const MUSICNO = '7303235'; +const SELF_URL = 'http://md.kasugano.cn/media/' + MUSICNO + '.ts'; +const OFFICIAL_URL = 'http://txog.ktvsky.com/E/ts/xxx/' + MUSICNO + '.ts'; + +function makeScenarios() { + const forbidden = { status: 200, body: JSON.stringify({ code: 403, msg: '请求被拒绝:检测到异常行为' }) }; + const adOnly = { status: 200, body: JSON.stringify({ code: 200, data: 'https://wb66.cn/f/d/7b7c9/' + MUSICNO + '.ts' }) }; + const notFound = { status: 200, body: JSON.stringify({ code: 404, msg: 'not in library' }) }; + return { + forbidden: { officialOkTimes: 0, officialFail: forbidden }, + adOnly: { officialOkTimes: 0, officialFail: adOnly }, + notFound: { officialOkTimes: 0, officialFail: notFound }, + quota2: { officialOkTimes: 2, officialFail: forbidden }, + }; +} + +function newHarness(scenario) { + const log = []; + const calls = { iphp: 0, dophp: 0, appToken: 0, appTokenMisses: 0 }; + const tokenCache = new Map(); + let officialDoPhpCount = 0; + const t0 = Date.now(); + const rel = () => Date.now() - t0; + + const api = { + httpGet(url) { + const u = String(url); + const at = rel(); + if (u.indexOf('/i.php') >= 0) { + calls.iphp++; + log.push({ t: at, kind: 'i.php', host: hostOf(u) }); + syncSleep(RTT_MS); + return JSON.stringify({ status: 200, body: JSON.stringify({ code: 200, token: 'tok-' + hostOf(u), heart_time: 5400 }) }); + } + if (u.indexOf('/music/do.php') >= 0) { + calls.dophp++; + const host = hostOf(u); + log.push({ t: at, kind: 'do.php', host: host }); + syncSleep(RTT_MS); + if (host === hostOf(OFFICIAL)) { + officialDoPhpCount++; + if (officialDoPhpCount <= scenario.officialOkTimes) { + return JSON.stringify({ status: 200, body: JSON.stringify({ code: 200, msg: 'SUCCESS', data: OFFICIAL_URL }) }); + } + const r = scenario.officialFail; + return JSON.stringify({ status: r.status, body: r.body }); + } + return JSON.stringify({ status: 200, body: JSON.stringify({ code: 200, msg: 'SUCCESS', data: SELF_URL }) }); + } + throw new Error('unexpected url ' + u); + }, + appToken(host) { + const key = String(host); + calls.appToken++; + if (tokenCache.has(key)) return tokenCache.get(key); + calls.appTokenMisses++; + log.push({ t: rel(), kind: 'appToken(首次)', host: key }); + syncSleep(RTT_MS); + tokenCache.set(key, 'app-token'); + return 'app-token'; + }, + invalidateAppToken(host) { tokenCache.delete(String(host)); }, + onSongSource() {}, + reportSongResult() {}, + log() {}, + }; + return { api, log, calls, rel }; +} + +async function runScenario(name, scenario, songs) { + delete require.cache[require.resolve(JS_FILE)]; + const fakeWindow = {}; + const prev = global.window; + global.window = fakeWindow; + const mod = require(JS_FILE); + if (prev === undefined) delete global.window; else global.window = prev; + + const KtvApi = fakeWindow.KtvApi || mod.KtvApi; + const h = newHarness(scenario); + fakeWindow.android = h.api; + mod.android = h.api; + + const api = new KtvApi({ + mac: '1ca410000001', sn: 'aabbccddeeff', maxDeviceRetries: 0, debug: false, + hosts: [OFFICIAL, SELF], + }); + + // 可选:模拟 App 启动 / 进入点歌列表时的**后台预热**(SourcePreheat 在 JS 路径上做的事): + // ① 构造 window.api 并逐个源取一次 /i.php token(同时热好 DNS/TCP/TLS); + // ② 逐个源问一次 App 令牌(android.appToken → Kotlin 侧按 host 缓存)。 + // 用 STUB_PREHEAT=1 打开,用于量"预热把首次点歌的哪些往返省掉了"。 + let preheatMs = null; + if (process.env.STUB_PREHEAT) { + const preheatAt = h.rel(); + const warmHosts = [OFFICIAL, SELF]; + for (const host of warmHosts) { + await api.getToken(host, false); + h.api.appToken(host); + } + preheatMs = h.rel() - preheatAt; + } + + const songsOut = []; + for (let i = 0; i < songs; i++) { + const start = h.rel(); + const before = h.log.length; + const url = await api.getSongUrl(MUSICNO, '720', false); + const end = h.rel(); + const reqs = h.log.slice(before); + const okReq = reqs.filter(r => r.kind === 'do.php' && r.host === hostOf(SELF))[0]; + songsOut.push({ + song: i + 1, + totalMs: end - start, + firstRequestSentAtMs: reqs.length ? reqs[0].t - start : null, + successSourceRequestSentAtMs: okReq ? okReq.t - start : null, + url: url, + requests: reqs.map(r => ((r.t - start) + 'ms ' + r.kind + ' ' + r.host)), + }); + } + return { + scenario: name, + stub: { httpRoundTripMs: RTT_MS, preheat: !!process.env.STUB_PREHEAT, preheatMs: preheatMs }, + totals: h.calls, + songs: songsOut, + }; +} + +(async () => { + const scenarios = makeScenarios(); + const out = []; + for (const name of Object.keys(scenarios)) out.push(await runScenario(name, scenarios[name], 4)); + console.log(JSON.stringify({ jsFile: JS_FILE, results: out }, null, 2)); +})();