点歌延迟优化:终态失败不退避 + 上游 i.php token 缓存 + 后台预热 + 分阶段耗时日志(V1.6.3/46,未发布)

This commit is contained in:
zijunchiang
2026-10-14 19:25:43 +08:00
parent d01269e917
commit c7f8224a73
21 changed files with 2779 additions and 132 deletions
+33 -3
View File
@@ -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=<mac>`,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 包完整),
所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件**
+2 -2
View File
@@ -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,否则
+66 -8
View File
@@ -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};
+59 -32
View File
@@ -112,6 +112,12 @@ class AppTokenSession(
private val retryAt = HashMap<String, Long>()
/** 单测用:每个 host 实际请求过几次 `/token`。 */
private val fetches = HashMap<String, Int>()
/**
* 每个 host 一把门闩:**同一 host 同时只发一次 `/token`**(其余调用等它的结果),
* 但**不同 host 之间互不阻塞** —— 一把全局锁会把"一个慢源"变成"所有源的取令牌都排队"
* (SSO 场景下就是点歌整体变慢)。
*/
private val gates = HashMap<String, Any>()
/** 单参构造:用系统时钟(生产路径)。 */
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** 时调用)。 */
+107 -39
View File
@@ -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<String> = runCatching {
val store = KtvStore()
store.load()
store.sourceHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT
}.getOrDefault(SourceHosts.DEFAULT)
private fun sourceHostsForJs(): List<String> = sourceConfigForJs().first
/** 设置项里的媒体地址允许清单(注入 JS 的 `CONFIG.ALLOWED_MEDIA_HOSTS`)。 */
private fun allowedMediaHostsForJs(): List<String> = runCatching {
private fun allowedMediaHostsForJs(): List<String> = sourceConfigForJs().second
/**
* **一次**读盘取出「源列表 + 媒体地址允许清单」。
*
* 取址关键路径上([getSongUrl])必须用这个版本:旧写法两个 getter 各调一次
* `KtvStore().load()`,等于在同一首歌的取址前面串两次 `state.json` 读取 + JSON 解析。
*/
private fun sourceConfigForJs(): Pair<List<String>, List<String>> = 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<String>, allowedHosts: List<String>): 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<String>): 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"}
);
@@ -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, "曲库加载中...")
+158
View File
@@ -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<String>,
private val runOn: (Runnable) -> Unit,
private val warmJsSession: (List<String>) -> 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<String, Long>()
/** 单测用:实际跑过的轮数。 */
@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"
}
}
@@ -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<SourcedSongUrl?>()
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<String>): 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()
}
@@ -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<String, Long>()
/** 记录一次点歌(同一编号重复点歌时以**最后一次**为准:那就是本轮取址的起点)。 */
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
}
}
@@ -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() }
}
@@ -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
@@ -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<String, Long>()
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() }
}
}
}
@@ -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 <host>/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<String, Entry>()
/** 每个 host 一把门闩:只用于"同一 host 同时只发一次 /i.php",不跨 host 阻塞。 */
private val gates = HashMap<String, Any>()
/** 失败后的短冷却(到该时刻为止不再尝试)。 */
private val retryAt = HashMap<String, Long>()
/** 单测用:每个 host 实际发过几次 `/i.php`。 */
private val fetches = HashMap<String, Int>()
/**
* 取当前源的 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,
)
@@ -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<String> results = java.util.Collections.synchronizedList(new java.util.ArrayList<String>());
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
@@ -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<String>()
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)
}
}
}
@@ -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<String> = Collections.synchronizedList(mutableListOf())
val calls: MutableList<String> = Collections.synchronizedList(mutableListOf())
val now = AtomicLong(1_700_000_000_000L)
fun preheat(
hosts: List<String>,
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("用时="))
}
}
@@ -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请求发出'"))
}
}
@@ -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 + "**"));
}
}
@@ -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<String> = 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<String?> = 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())
}
}
+818
View File
@@ -0,0 +1,818 @@
# App 歌曲源获取流程(麦动KTV Android 端)
> **这段话给不看代码的人**
>
> 用户点一首歌,App 先看本地有没有、没有就去"歌曲源"换一条下载地址。取址有**两套实现**:
> 隐藏 WebView 里的热更新脚本 `ktv_api.js`(主路径)和 Kotlin 的 `SongUrlResolver`(原生兜底,
> 给 WebView 不可用的老盒子用)。两套协议完全一样:先 `GET <源>/i.php` 拿 token,
> 再 `GET <源>/music/do.php` 拿地址;多个源按顺序试、失败的源"沉到队尾"。
> 拿到的地址**必须过一道可信闸门**(`AdVideoFilter.isTrustedSongUrl`)——源站有时会返回**广告视频**
> 冒充歌曲(历史上真发生过,广告被当歌下载播放),所以地址里的 `.ts` 文件名必须等于点歌编号,
> 命中广告域名/特征一律拒收。拿到可信地址后:交给 okdownload 下载落盘 → 播放,
> 同时顺带把这条地址 **POST 给自建服务器** `/push_download`(省服务器一次上游取址)。
> 对接自建源时多一个 `GET <源>/token` 拿 App 令牌(请求头 `X-App-Token`),
> 官方源不认这个接口(404/405)会被记住、之后不再尝试。全部源都失败时弹一句
> 「远程源已全部失效,等待新版本修复」(15 分钟节流)。
*本文所有函数名、参数名、默认值、行号均从当前工作区代码逐条核对;无法确认的地方显式标注「(未在代码中确认)」。*
*核对基准:`maidong-ktv/app/src/main/`(**versionName 1.6.3 / versionCode 46**,见 `app/build.gradle`)。*
> **2026-10-15(V1.6.3/46)更新说明 —— 点歌延迟优化**
> 本次**只重新核对了与"点歌延迟"直接相关的章节**(§0 文件索引、§3.3 JS 重试语义、§4 令牌、
> §5.1 `heart_time`、§13 新增的《点歌延迟优化》);其余章节沿用上一版(1.6.0/43 基准)的
> 核对结果,**行号可能已漂移**,引用前请以代码为准。
> 改动见 **§13**:JS 终态失败不再退避重试、上游 `/i.php` token 按 host 缓存(TTL=`heart_time`)、
> App 令牌按 host 单飞、`SourcePreheat` 后台预热、`SongFetchTrace` 分阶段耗时日志、
> `api.php` 移出取址关键路径。
---
## 0. 代码索引(本文引用到的文件)
| 文件 | 角色 |
|---|---|
| `java/com/local/ktv/SongApiClient.kt` | **四条取址路径的唯一汇合点** + 点歌推送切入点 + 主线程保护 |
| `java/com/local/ktv/SongUrlResolver.kt` | 原生兜底取址(复刻 JS 协议,`/i.php` → `/music/do.php`) |
| `java/com/local/ktv/KtvJsBridge.kt` | 隐藏 WebView 宿主 + 裸 Socket「干净请求」通道 + 地址闸门第二处 |
| `assets/mobile/ktv_api.js` | JS 主取址路径(可热更新),协议第 4 处闸门 |
| `java/com/local/ktv/AppToken.kt` | App 令牌纯逻辑(取/缓存/不支持记忆/401 只重试一次) |
| `java/com/local/ktv/AppTokenClient.kt` | 令牌的 Android 胶水(接到干净 GET 通道) |
| `java/com/local/ktv/SourceHosts.kt` | 源列表 / 媒体允许清单的归一化(纯逻辑) |
| `java/com/local/ktv/AdVideoFilter.kt` | **地址可信性唯一闸门** `isTrustedSongUrl` |
| `java/com/local/ktv/PushDownload.kt` | 点歌推送纯逻辑(端点/表单/去重) |
| `java/com/local/ktv/PushDownloadClient.kt` | 点歌推送 Android 胶水(后台线程 + 8s 超时) |
| `java/com/local/ktv/KtvStore.kt` | 设置持久化(`state.json`:`sourceHosts`/`allowedMediaHosts`/`pushServerUrl`/`pushToken`/`deviceIdentityJson`) |
| `java/com/local/ktv/DeviceIdentity.kt` | 假盒子身份(`mac` / `sn`,生成一次固定不变) |
| `java/com/local/ktv/Whoami.kt` | `POST hbeat.ktvsky.com/whoami` 心跳(24 字段,纯逻辑) |
| `java/com/local/ktv/MainActivity.kt` | 点歌入口、启动初始化、设置页、网络恢复、心跳调度 |
| `java/com/local/ktv/SongOkDownloadManager.kt` | 下载层(含**最终兜底闸门**、重试、落盘校验) |
| `java/com/local/ktv/Song.kt` | `Song.getDownloadUrl()`(曲库条目取址入口之一) |
| `java/com/local/ktv/Eac19WindowGuard.kt` | 全部源失效的用户可见提示(15 分钟节流) |
| `java/com/local/ktv/MiliLog.kt` | 远程日志(GlogCenter)+ 本地 `logs/ktv.log` |
| `java/com/local/ktv/SourceTokenCache.kt` | **上游 `/i.php` token 的按 host 缓存**(TTL=`heart_time`、单飞、纯 JVM) |
| `java/com/local/ktv/Preheat.kt` / `SourcePreheat.kt` | 取址**预热**纯逻辑(节流/单飞/步骤编排)+ Android 接线(JS 会话·i.php·api.php·DNS/TCP/TLS·/token) |
| `java/com/local/ktv/SongFetchTrace.kt` | 取址链路的**分阶段耗时**日志(`FETCH-TIME …`,只写本地) |
| `java/com/local/ktv/SongFileValidator.kt` | **内容**校验(≥6MB / ≥60s / TS 包完整)——对广告无效 |
| `AndroidManifest.xml` | `usesCleartextTraffic="true"` + `networkSecurityConfig`(`http://` 源可用) |
---
## 1. 一图流
```mermaid
flowchart TD
A["点歌入口<br/>MainActivity.orderSong:4930 → handleSong:4979<br/>扫码/远程 order:3573 → handleSong"] --> B["addToQueue:10382<br/>本地已就绪? → addReadySongToQueue:10403 → play:5145"]
B --> C["下载: downloadSong:5505<br/>→ SongOkDownloadManager.download:156(io 线程)"]
C --> D["buildDownloadUrl:248<br/>① 校验 song.downloadUrl 缓存:256<br/>② SongApiClient.getSongDownloadUrl():269"]
D --> E{"SongApiClient<br/>resolveSongDownloadUrl:82"}
E -->|"主线程?"| F["fetchFromDb:146(仅 DB,禁网络)"]
E -->|"桥不可用"| G["resolveNatively:140<br/>SongUrlResolver.resolve:218"]
E -->|"桥就绪"| H["bridge.getSongUrl:286<br/>等 15s:102 / 185s:122"]
H -->|"JS 返回地址"| I["complete:391<br/>AdVideoFilter.resolve:210 + isTrustedDownloadUrl:368"]
I -->|"null"| G
H -->|"null/超时"| G
G -->|"null"| F
F -->|"null"| J["返回空 → 「无法生成下载地址」"]
I -->|"url"| K
G -->|"url(先过 isValidSongUrl:433)"| K
F -->|"url(先过 isLikelyDemoUrl:171 + isTrustedSongUrl:153)"| K
K["SongApiClient 4 参入口:72<br/>PushDownloadClient.offer:33(fire-and-forget)"] --> L["buildDownloadUrl 最终闸门:276<br/>AdVideoFilter.isTrustedSongUrl"]
L -->|"不可信"| M["purgeCachedSongFile:301 + 上报「地址不可信」"]
L -->|"可信"| N["startDownloadTask:317 → okdownload → finalizeDownload:403<br/>SongFileValidator.inspect:432 → 落盘"]
N --> O["onDownloadComplete → playLocalFile:5191 → 播放"]
M --> P["Eac19WindowGuard.noteAllSourcesFailed:318 → onAllFailed 弹窗"]
J --> P
```
### 1.1 分步表(点歌 → 取址 → 校验 → 播放/下载 → 推送上报)
| # | 步骤 | 函数 | 文件:行 |
|---|---|---|---|
| 1 | 点歌(本地 UI) | `orderSong()` → `handleSong()` | `MainActivity.kt:4930` / `:4979` |
| 2 | 点歌(远程/扫码 HTTP `order`) | `order(query)` → `main.post { handleSong(song) }` | `MainActivity.kt:3573` |
| 3 | 入队(未就绪先下载) | `addToQueue()` → `downloadSong()` | `MainActivity.kt:10382` / `:10394` |
| 4 | 启动下载 | `play()` → `startDownloadAndPlay()` → `downloadSong()` | `MainActivity.kt:5145` / `:5327` / `:5505` |
| 5 | 下载管理器入口 | `SongOkDownloadManager.download()` | `SongOkDownloadManager.kt:156` |
| 6 | 组装地址(先查缓存地址 + 最终闸门) | `buildDownloadUrl()` | `SongOkDownloadManager.kt:248` |
| 7 | **四路径汇合点** | `SongApiClient.getSongDownloadUrl(4 参)` | `SongApiClient.kt:72` |
| 8 | 取址实现(分流) | `resolveSongDownloadUrl()` | `SongApiClient.kt:82` |
| 9 | JS 主路径 | `KtvJsBridge.getSongUrl()` → `ktv_api.js#getSongUrl` | `KtvJsBridge.kt:286` / `ktv_api.js:427` |
| 10 | 原生兜底路径 | `SongUrlResolver.resolve()` | `SongUrlResolver.kt:218` |
| 11 | DB 回落 / 主线程 DB 兜底 | `fetchFromDb()` | `SongApiClient.kt:146` / 调用点 `:91` |
| 12 | 令牌(自建源) | `AppTokenClient.withToken()` → `AppTokenSession.withToken()` | `AppTokenClient.kt:57` / `AppToken.kt:195` |
| 13 | 地址可信闸门(原生/JS/DB) | `AdVideoFilter.isTrustedSongUrl()` | `AdVideoFilter.kt:126`(2 参)/ `:131`(3 参) |
| 14 | 点歌推送 | `PushDownloadClient.offer()` → `submit()` → `post()` | `PushDownloadClient.kt:33` / `PushDownload.kt:135` / `PushDownloadClient.kt:75` |
| 15 | 最终兜底闸门 + 上报 | `buildDownloadUrl()` 内联判定 | `SongOkDownloadManager.kt:276` / `:284` |
| 16 | 落盘 + 内容校验 | `startDownloadTask()` → `finalizeDownload()` | `SongOkDownloadManager.kt:317` / `:403`(`inspect` 在 `:432`) |
| 17 | 失败提示 | `Eac19WindowGuard.noteAllSourcesFailed()` | `SongUrlResolver.kt:318` → `Eac19WindowGuard.kt:73` |
---
## 2. 入口与四条取址路径
### 2.1 点歌入口(谁最终会去取址)
| 入口 | 位置 | 说明 |
|---|---|---|
| 主界面/搜索/排行/收藏/歌单点击 | `handleSong()` `MainActivity.kt:4979`(`orderSong()` `:4930` 是外部组件用的公共封装) | 全部收敛到 `addToQueue()` `:10382` |
| 远程点歌页(手机扫码) | `MainActivity.kt:3573`(`override fun order(query)` 里 `main.post { handleSong(song) }`) | 走 `LocalRemoteServer` 的 `order` 回调 |
| 已点列表点击播放 | `playSong()` `:4968` → `play()` `:5145` | 已就绪直接播,否则下载 |
| 曲库条目取址(另一条独立入口) | `Song.getDownloadUrl()` `Song.kt:76`,内部调用 `SongApiClient.getSongDownloadUrl(..., song.title)` `:80-82` | 直接命中汇合点(不经过 `SongOkDownloadManager`) |
| 备用/历史路径 | `MainActivity.download(song)` `:4984` | 直接用 `song.downloadUrl`/`videoUrl` 下载,**不取址、不过闸门**(见 §11 注意事项) |
启动时装配:`SongApiClient.init(this)` `MainActivity.kt:577`,内部 `KtvJsBridge(context)` +
`SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) }`(`SongApiClient.kt:30`)+
`SongUrlResolver.installPost { ... }`(`:32`)+ `AppTokenClient.install { ... }`(`:34`)。
### 2.2 四条取址路径
| 路径 | 进入条件 | 关键函数 | 校验位置 | 失败后 |
|---|---|---|---|---|
| **① JS 桥路径** | 非主线程 && `bridge != null` && `!bridge.isUnavailable()`,等待 `isReady()` 最多 **15 秒**(`SongApiClient.kt:102`),并用 `CountDownLatch` 等 **185 秒**(`:122`) | `KtvJsBridge.getSongUrl` `:286` → JS `KtvApi.getSongUrl` `ktv_api.js:427` | JS `isDemoUrl` `ktv_api.js:174`;回来后再过 `KtvJsBridge.complete` `:391`(`AdVideoFilter.resolve` + `isTrustedDownloadUrl`) | 返回 null/超时 → 原生兜底(`:128`) |
| **② 原生兜底路径** | 桥为 null(`SongApiClient.kt:93`)、桥已判不可用(`:96-98`)、15s 未就绪(`:109-116`)、JS 取不到(`:128`) | `resolveNatively()` `:140` → `SongUrlResolver.resolve()` `:218` | `SongUrlResolver.isValidSongUrl` `:433` → `AdVideoFilter.isTrustedSongUrl` | 返回 null → DB 回落 |
| **③ DB 回落路径** | 前两条都没给地址(`?:` 链的最后一环) | `fetchFromDb()` `:146`(`MuseDatabase().getCloudUrl(musicno)`) | `!isLikelyDemoUrl(url)`(`:171`)**且** `AdVideoFilter.isTrustedSongUrl(url, musicno)`(`:153`) | 返回 null → 整个取址失败 |
| **④ 主线程 DB 兜底** | `Looper.myLooper() == Looper.getMainLooper()`(`:89`)——**主线程只允许走本地 DB** | `fetchFromDb()` `:91` | 同 ③ | 返回 null(主线程绝不发起网络取址,避免 ANR) |
> 备用第 5 条:`Song.getDownloadUrl()` 在最后还有一条**无校验的**兜底
> `https://pub.cdn.cherryonline.cn/video/cloud-song/<filename>`(`Song.kt:86-88`),
> 仅在前三条全部失败时返回。它**不经过** `AdVideoFilter`。
### 2.3 汇合点为什么在 `SongApiClient.getSongDownloadUrl()`
代码里给出的理由(`SongApiClient.kt:59-70`、`SongOkDownloadManager.kt:267-269`):
1. **四条路径都在它下面**:①②③④ 的实现全部在私有 `resolveSongDownloadUrl()`(`:82`)内部串联,
因此只有这个函数能保证「**只要真的拿到一条地址,就恰好推送一次**」。
挂在 JS/原生各自的成功分支必然漏掉某条路径 —— 广告闸门历史上就是这么漏的。
2. **推送是旁路**:`PushDownloadClient.offer()`(`:33`)在返回之前 fire-and-forget,
不改写返回值、不影响播放(`:74-78`)。
3. **主线程保护**:它同时也是"主线程禁止网络 I/O"的判定点(`:89-92`)。
4. 注意它是**两道防线中的第一道**:真正"下载地址可信"的**最终**判定在
`SongOkDownloadManager.buildDownloadUrl`(`:276`),因为 `song.downloadUrl` 缓存
(来自曲库/上次取址)也必须在用之前校验(`:256-265`)。
---
## 3. 源列表与选源
### 3.1 默认值(两处必须逐项一致)
| 常量 | 值 | 位置 |
|---|---|---|
| `SourceHosts.DEFAULT` | `["https://mdmuse.kasugano.cn", "http://zk.ktv1.top:6677"]` | `SourceHosts.kt:31-34` |
| `SongUrlResolver.DEFAULT_HOSTS` | 同上(仅用于 `hostOrder` 初值与日志) | `SongUrlResolver.kt:62-65` |
| `ktv_api.js CONFIG.HOSTS` | 同上 | `ktv_api.js:29-32` |
| `ktv_api.js CONFIG.HOST` | `"https://mdmuse.kasugano.cn"` | `ktv_api.js:33`(**注意:该字段在 JS 里从未被读取**,见 §11) |
| `SourceHosts.SELF_HOST` | `"mdmuse.kasugano.cn"` | `SourceHosts.kt:37` |
| `SourceHosts.SELF_HOST_LAN` | `"192.168.100.232"` | `SourceHosts.kt:45` |
| `SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS` | `["md.kasugano.cn", "192.168.100.232"]` | `SourceHosts.kt:54` |
约定:**自建源优先、官方源兜底**(`SourceHosts.kt:13-20`、`SongUrlResolver.kt:52-61`)。
### 3.2 设置项如何注入
| 设置键 | 默认 | 读 | 原生生效 | JS 生效 |
|---|---|---|---|---|
| `sourceHosts` | `SourceHosts.DEFAULT` | `KtvStore.sourceHosts` `KtvStore.kt:87`、`load()` `:156-157`(经 `SourceHosts.parseList` 归一化,**空 → DEFAULT**) | `MainActivity.applySourceSettings()` `:9417` → `SongUrlResolver.applySourceHosts()` `SongUrlResolver.kt:141`(重排 `hostOrder`,`:142-146`) | `KtvJsBridge.getSongUrl` 内 `sourceHostsForJs()` `:256` → 覆写 `window.KtvApiConfig.HOSTS`/`HOST` `:321-322`,然后 `new window.KtvApi(...)` `:325` |
| `allowedMediaHosts` | `SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS` | `KtvStore.allowedMediaHosts` `KtvStore.kt:96`、`load()` `:158-159` | `AdVideoFilter.setAllowedMediaHosts()` `AdVideoFilter.kt:73`(`:9419` 调用) | 覆写 `window.KtvApiConfig.ALLOWED_MEDIA_HOSTS` `:323` |
| `pushServerUrl` / `pushToken` | `""` / `"local"` | `KtvStore.kt:76` / `:78`、`load()` `:153-154` | — | —(推送用,见 §8) |
归一化规则(`SourceHosts.parseList` `:68`、`normalizeHost` `:76`):去空白、去尾斜杠、
**只留 `http://`/`https://` 开头**、按出现顺序去重;空列表回落 `DEFAULT`。
允许清单另有一套归一化 `normalizeAllowedHost` `:102`(接受整条 URL 粘贴,抽出纯主机名、小写、去端口/路径)。
设置页分隔符:换行、逗号、分号、空白(`SEPARATORS` `:57`)。
设置页入口:`showSourceHostsDialog()` `MainActivity.kt:9427`(保存 → `applySourceHostsText` `KtvStore.kt:265` →
`saveState()` → `applySourceSettings()` `:9451`)、`showAllowedMediaHostsDialog()` `:9463`。
> ⚠️ **JS 侧只在下一次"重建会话"时才会拿到新列表**:注入代码写在 `if (!window.api) { ... }` 里
> (`KtvJsBridge.kt:314-331`)。设置页保存只调 `applySourceSettings()`,**不会**清 `window.api`,
> 因此已建好的 `window.api.sourceOrder`(`ktv_api.js:284` 在构造时快照 `CONFIG.HOSTS`)仍按旧顺序选源。
> 原生路径**立刻**生效。要让 JS 也立刻换源,需要 `SongApiClient.resetSourceSession()`
> (`SongApiClient.kt:194` → `KtvJsBridge.resetApiSession()` `:275` → 置空 `window.api`/`_apiInitPromise`,
> `RESET_SESSION_JS` `:66-72`)。目前调用点只有:断网/换网恢复 `MainActivity.kt:3830`、
> 下载重试 `SongOkDownloadManager.kt:523`(经 `clearTokenCache`)、改设备身份 `MainActivity.kt:3774`。
### 3.3 按顺序尝试 / 失败切换(两套实现同一语义)
**原生**(`SongUrlResolver.resolve` `:218`):
```
hosts = hostOrder 快照(:236)→ 按试用额度截断 take(maxHosts)(:240-244)
for host in hosts: // :250
token = fetchToken(host) // :251 → :324 GET /i.php
失败 → demote(host) 并 continue // :255-260
url = fetchSongUrl(host, token) // :261 → :340 GET /music/do.php
成功 && isValidSongUrl → promote(host); lastHost=host; blockedUntil=0; return url // :266-276
失败 → demote(host) // :288
```
* `demote(host)` `:153`:把该源**移到队尾**(只在不是最后一个时才动);`promote(host)` `:163`:移到队首。
`hostOrder` 是 `Collections.synchronizedList`(`:68`),进程内有效、**不落盘**(重启回默认顺序)。
* JS 侧同名逻辑:`demoteSource` `ktv_api.js:297`、`promoteSource` `:307`、`this.sourceOrder` `:284`。
* **一次取址最多试几个源**:受 `ensureConfig()` 的 `trial_times` 限制(`SongUrlResolver.kt:240`)。
`ensureConfig`(`:184`)只对**当前队首** POST `<host>/api.php`(干净 POST,`KtvJsBridge.rawCleanPost` `:234`),
缓存 **30 分钟**(`CONFIG_TTL_MS` `:50`),**失败也写负缓存**(`:192`,因为 `zk.ktv1.top:6677` 没有 `api.php`)。
按当前默认顺序,队首是自建源(其 `api.php` 返回 `trial_times:0`)→ `maxHosts == allHosts.size` → 不受限。
* JS 侧还有一层**设备重试**:`for deviceRound in 0..maxDeviceRetries`(`ktv_api.js:433`),
但桥注入的是 `maxDeviceRetries: 0`(`KtvJsBridge.kt:325`)→ **只跑一轮**,不会再随机换设备身份。
* **JS 重试/退避语义(V1.6.3 已改,见 §13)**:首次 `do.php` **永远立即发出**;失败后按
`isTerminalFailure(httpStatus, code)` 分类 —— **终态失败**(HTTP 4xx / 业务码 403 / 404)
**不退避、不重试**,直接 `demoteSource` 换下一个源;只有广告/错号资源、HTTP 5xx、异常才退避
`RETRY_BACKOFF_MS`(**800ms**;旧实现无条件 `sleep(3000)`)后重试一次。
令牌被拒(401 + `令牌无效或已过期`)仍是"清缓存 → 立即重试一次、不退避"。
* `ls` 取值:JS 只试 `['1']`(`ktv_api.js:451`,注释说明其余值会被风控直接拒);
原生固定 `ls=1`(`SongUrlResolver.kt:351`)。
### 3.4 切源对令牌缓存 / 「不支持令牌」记忆的影响
| 事件 | 令牌缓存 | 「不支持令牌」记忆 | 说明 |
|---|---|---|---|
| 换成另一个源 | 各源**独立**一个 key(`AppTokenSession.key()` `AppToken.kt:209`,按 host 归一化) | 同上,按 host 记 | 互不影响 |
| `resetApiSession()`(JS 侧) | **不影响 Kotlin 的 `AppTokenSession`**(它是 object 级进程内缓存);JS 侧只是把整个 `window.api`(连同它的 `tokens`,**官方源 token**,`ktv_api.js:281`/`:284`)置空丢弃(`RESET_SESSION_JS` `KtvJsBridge.kt:66-72`) | 不受影响 | 断网恢复后原生/JS 会重新"按需"用旧 App 令牌,直到它过期(提前 30s)或被 401 打回 |
| 401 令牌被拒 | `invalidate(host)` 清该源令牌 + 冷却计时 | **同时把 "不支持" 记忆一起清掉**(安全网,`AppToken.kt:169-170`) | 会再试一次 `/token` |
| App 重启 | 全部丢失 | 全部丢失 | 都是**内存**结构(`AppToken.kt:108-114`) |
---
## 4. 令牌流程(App 令牌,自建源专用)
服务端契约(参考 `selfhost-source/server/ktv/apptoken.py`):
`GET/POST /token` → `{"ok":true,"token":"<43 字符>","expires_in":600,…}`,`Cache-Control: no-store`;
受管路径默认 `DEFAULT_PATHS = ("/music/do.php", "/push_download")`(`apptoken.py:63`);
`LEGACY_CHECK_PATHS = ("/i.php",)`(`:66`);请求头名 **`X-App-Token`**(`_presented()` `:414-432`);
`app_token_enforce` 默认 **False**="带了就校验"(`:206`、`config.py:81`)。
### 4.1 常量与触发时机
| 常量 | 值 | 位置 |
|---|---|---|
| `AppToken.PATH` | `"/token"` | `AppToken.kt:25` |
| `AppToken.HEADER` | `"X-App-Token"` | `:28` |
| `AppToken.DEFAULT_EXPIRES_IN_SEC` | `600` | `:31` |
| `AppToken.TIMEOUT_MS` | `8000` | `:34` |
| `AppToken.EARLY_EXPIRE_MS` | `30_000`(**提前 30 秒**过期) | `:37` |
| `AppToken.FAILURE_BACKOFF_MS` | `60_000`(其它失败 60 秒冷却) | `:45` |
| `AppToken.UNAUTHORIZED_MARKER` | `"令牌无效或已过期"` | `:48` |
触发时机:只在一个地方 —— **调 `/music/do.php` 之前**(另外由 `SourcePreheat` 在后台**提前**取一次,
见 §13:令牌 10 分钟有效,懒到第一次点歌会白等一次往返)。
原生 `SongUrlResolver.fetchSongUrl` `:359`:`AppTokenClient.withToken(host) { appToken -> callEnvelope(channel, url, appToken) }`;
JS `ktv_api.js:413-414`:`httpGet(..., appTokenHeader(host))`,而 `appTokenHeader`(`:203`)回调 Kotlin
`NativeBridge.appToken(host)`(`KtvJsBridge.kt:534`)→ `AppTokenClient.tokenFor`(`AppTokenClient.kt:39`);
推送侧 `PushDownloadClient.pushNow` `:61`。`tokenFor` 内部先查缓存,未过期直接返回,**不会**每首歌都打 `/token`(`AppToken.kt:129-131`)。
### 4.2 状态机(`AppTokenSession.tokenFor` `AppToken.kt:125-161`)
| 情形 | 行为 | 行号 |
|---|---|---|
| 已记住"不支持" | 直接返回 null(**连请求都不发**) | `:127` |
| 缓存未过期 | 复用 | `:129-131` |
| 冷却中 | 返回 null(不带令牌、不发请求) | `:133` |
| `GET <host>/token` 网络异常/超时 | 冷却 60s,返回 null(**请求照常发出,只是不带令牌**) | `:137-140` |
| **HTTP 404 / 405** | 加入 `unsupported` → **永久**(进程内)记住,返回 null | `:141-146` |
| 其它非 2xx(5xx/429…) | 冷却 60s | `:147-150` |
| 2xx 但解析不出 `token` | 冷却 60s | `:151-155` |
| 2xx 拿到 token | 缓存,TTL = `(expires_in ?? 600)*1000 - 30000`,最小 1 秒 | `:156-158` |
| **401 / body 含"令牌无效或已过期"** | `withToken` 里 `invalidate(host)` → 重新 `tokenFor` → **再发一次请求,只重试一次** | `:195-201`、判定 `:204-207` |
关键细节:
* **按 host 单飞(V1.6.3)**:`tokenFor` 的网络 I/O 只在**本 host 的门闩**里做 —— 同一 host
并发调用只发一次 `/token`,不同 host **互不阻塞**(旧实现把网络 I/O 放在一把全局锁里,
一个慢源会把其它源的取令牌一起排队)。
* `isUnsupportedStatus(status)` = `status == 404 || status == 405`(`:56`)。
* `isUnauthorized(status, body)` = `status == 401 || body.contains("令牌无效或已过期")`(`:63-64`)——
有的反代把 401 包成 200,所以按文案兜底。
* 重试语义:`withToken` 第二次调用的结果**无论对错都直接返回**(`:200` 注释与实现)。
并且默认判定要求"**本次确实带了令牌**"才算令牌被拒(`:206` `token != null && ...`)——
没带令牌的 401 属于源站自己的事,不当令牌问题处理。
* 失败一律**降级为"不带令牌"**,绝不让取址失败在令牌上:`AppTokenClient.withToken` 的
`getOrElse { ...; request(null) }`(`AppTokenClient.kt:58-62`)、`tokenFor` 的 `runCatching`(`:40-42`)。
* token 逻辑**只在 Kotlin 一处**(`AppTokenSession`),JS 只是"问一次 + 带上头 + 被拒时让原生清缓存"
(`ktv_api.js:199-219`)。
### 4.3 哪些请求带令牌
| 请求 | 带 `X-App-Token`? | 位置 |
|---|---|---|
| `GET /i.php` | **不带**(传 `null`)。注释:与官方源逐字节对齐,服务端把它列在 `LEGACY_CHECK_PATHS`,"带了就校验",我们没带 → 老形态完全不变 | `SongUrlResolver.fetchToken` `:330` 走 `getJson` `:408`(内部 `callEnvelope(channel, url, null)` `:409`);JS `getToken` `ktv_api.js:373` 用 `httpGet(url)` 不传 extraHeaders |
| `GET /music/do.php` | **带**(自适应:先试 `/token`,官方源 404/405 会被记住后不再尝试) | `SongUrlResolver.kt:359`;`ktv_api.js:413-414` |
| `POST <push>/push_download` | **带**(仅当推送目标是自建源;取不到就按老形态推,服务端也认 body 里的 `push_token`) | `PushDownloadClient.kt:58-62`、`:86` |
| `POST /api.php`(取配置) | 不带 | `SongUrlResolver.ensureConfig` `:188` → `rawCleanPost(url, form)`(appToken 省略) |
| `whoami` 心跳 | 不带(`Content-Type: text/plain` + 空 body) | `MainActivity.kt:3704-3712` |
| `GET /token` 自身 | 不带(只发 `Accept`) | `AppTokenClient.install` `:29` → `bridge.rawCleanGet(url, null, AppToken.TIMEOUT_MS)` `SongApiClient.kt:34` |
`AppTokenClient.envelope` 在 404/405 时只写一条**本地**日志
`APP-TOKEN 源不支持令牌接口(HTTP $status),之后不再尝试 source=$url`(`:77-78`)。
---
## 5. 协议细节(逐字核对)
### 5.1 `GET <host>/i.php`(取 token)
**原生**(`SongUrlResolver.fetchToken` `:324-338`):
```kotlin
val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
val sign = md5(params + APP_KEY)
GET "$host/i.php?$params&sign=$sign"
```
**JS**(`ktv_api.js:363-370`)拼接顺序**逐字节相同**(`:364-368` 与上表一致)。
| 参数 | 值 / 来源 |
|---|---|
| `appid` | `SongUrlResolver.APP_ID` = `d4eeacc6cec3434fbc8c41608a3056a0`(`:35`)= `ktv_api.js CONFIG.APP_ID` `:19` |
| `mac` | **`<mac>_<sn>`** —— `DeviceIdentity.effective().mac`(`SongUrlResolver.kt:107`)+ `"_"` + `sn`。JS 用 `self.mac + '_' + self.sn`(`ktv_api.js:365`) |
| `sn` | `DeviceIdentity.effective().sn`(`SongUrlResolver.kt:105`);JS 由桥注入 `sn: DEVICE_SN`(`KtvJsBridge.kt:325`) |
| `time` | 秒级 Unix 时间戳:原生 `System.currentTimeMillis()/1000`(`:325`);JS `Math.floor(Date.now()/1000)`(`:363`) |
| `ver` | `SongUrlResolver.VER = "2.0"`(`:38`);JS 由桥覆写 `KtvApiConfig.VER = '2.0'`(`KtvJsBridge.kt:318`) |
| `vn` | `SongUrlResolver.VN = "4.1.3.03281430"`(`:39`);JS 由桥覆写 `KtvApiConfig.VN = '4.1.3.03281430'`(`:319`);与 `Whoami.VN`(`Whoami.kt:40`)同值 |
| `sign` | `md5(params + APP_KEY)`;`APP_KEY` = `024210cba40d4385a93e6c2d3249bfb5`(`:36`) |
| 请求头 | 只有 `Accept: */*`(原生 `rawCleanGet` `KtvJsBridge.kt:220`;JS `httpGet` `ktv_api.js:234`) |
返回(App 侧实际读取的字段):
| 字段 | App 用法 |
|---|---|
| `code` | `!= 200` → 该源取 token 失败(`SongUrlResolver.kt:333`、`ktv_api.js:376`);`== 403` 时原生置 `sawForbidden`(`:332`) |
| `token` | `json.optString("token")`(`:337`)/ `data.token`(`ktv_api.js:377`) |
| `msg` | 只用于本地日志(`SongUrlResolver.kt:334`) |
> 服务端还会返回 `retry` / `ordertype` / `end_date` / `heart_time` 等字段
> (`selfhost-source/server/ktv/service.py:70-73`)。
> ⚠️ **V1.6.3 起 `heart_time` 被消费**:原生兜底路径把它当作 `/i.php` token 的**缓存时长**
> (秒;实测 5400 = 90 分钟,见 `SourceTokenCache`)。`retry` / `ordertype` / `end_date`
> 仍**无消费方**(语义未在 App 代码中确认)。
### 5.2 `GET <host>/music/do.php`(取播放地址)
**原生**(`SongUrlResolver.fetchSongUrl` `:340-378`):
```kotlin
val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" +
"&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token"
val sign = md5(params + SDK_KEY)
GET "$host/music/do.php?$params&sign=$sign" // + 可选请求头 X-App-Token
```
**JS**(`ktv_api.js:401-413`)拼接顺序逐字节相同。
| 参数 | 值 / 来源 |
|---|---|
| `appid` | 同 §5.1 |
| `device` | **`<mac>_<sn>`**(注意与 `i.php` 的 `mac` 参数同名不同键) |
| `ish265` | `"1"` / `"0"`;原生入参默认 `h265 = false`(`SongUrlResolver.resolve` `:218`),调用链传 `false`(`SongOkDownloadManager.kt:269`、`MainActivity.kt:5239`) |
| `ls` | 恒为 `"1"`(原生 `:351`;JS `lsValues = ['1']` `:451`) |
| `musicno` | 点歌编号(`SongOkDownloadManager.buildDownloadUrl` 的 `musicNo`,`:254`) |
| `resolution` | 调用链固定 `"720"`(`SongOkDownloadManager.kt:269`、`MainActivity.kt:5239`);`resolve` 默认参数也是 `"720"`(`:218`) |
| `sn` | 同 §5.1 |
| `time` | 秒级时间戳 |
| `token` | **`/i.php` 返回的那个 token**(源站 token,不是 App 令牌);原样拼接、**不做 URL 编码**(注释强调"原始 token 中的 `=`"必须保留,`:350`、`ktv_api.js:400`) |
| `sign` | `md5(params + SDK_KEY)`;`SDK_KEY` = `19042303a8374f67ae3fe1e25c97936f`(`:37`) |
| 额外请求头 | `X-App-Token`(自建源,可有可无) |
返回结构与字段:
| 字段 | 类型 | App 用法 |
|---|---|---|
| `code` | int | `200` = 成功;其它 = 该源失败。`403` 时原生置 `sawForbidden`(`:373`) |
| `msg` | string | 只进本地日志(`:374`);服务端成功时是 `"SUCCESS"`(`service.py:89`) |
| `data` | string | 媒体地址,`takeIf { it.isNotEmpty() }`(`:377`);JS 取 `data.data`(`ktv_api.js:490`) |
| `limit` | int | **App 侧从不读取**(全仓 grep `limit` 无命中)。服务端在成功时返回 `"limit": 2000`(`service.py:89`),语义未在 App 代码中确认 |
JS 侧还有额外约束:返回地址必须匹配 `/^https?:\/\//i`(`ktv_api.js:492`),
且 HTTP 非 200 时整体视为失败(`_fetchSongUrl` `:415-417` 返回 `{code:null, data:'', httpStatus, body}`)。
### 5.3 `mac` / `device` / `sn` 的来源(假盒子身份)
* 统一来自 `DeviceIdentity`(**Object 单例**),三个请求(whoami / i.php / do.php)**同源**:
* `KtvJsBridge.DEVICE_MAC` `:55` / `DEVICE_SN` `:56` → 注入 JS(`:325`、`:329-330`)
* `SongUrlResolver.sn` `:105` / `mac` `:107`
* `MainActivity.sendDeviceHeartbeat` 用 `DeviceIdentity.effective().toFacts(pkn = store.whoamiPkn)`
(`:3695-3697`,`toFacts` 见 `DeviceIdentity.kt:181-196`)
* 生成规则(`DeviceIdentity.generate` `:329-357`):随机挑一套**真盒子模板**(`:84-120`:
Amlogic S905X/S905X3/S905X4、Rockchip RK3328、Realtek RTD1295)→
`mac = OUI + randomHex(6)`(`:331`、`newMac` `:422`,OUI 是真实盒子厂商前缀)→
`sn = randomHex(12)`(`:337`)→ 落盘进 `KtvStore.deviceIdentityJson`(`install` `:278-290`、`sink` `:281-286`)。
* **生成一次后固定不变**;只有设置页「重新生成设备身份」会换(`MainActivity.kt:3744` / `:3768`)。
换身份会同时 `SongApiClient.clearTokenCache()`(`:3774`)并补一次 whoami(`:3775`)。
* `validate()` `:364-416` 校验自洽(12 位小写 hex、单播位、OUI 在白名单、`_api` 与 `d_release` 对应、
fingerprint 形态…);**不合法就重新生成**(`install` `:280`)。
* ⚠️ 这是**构造值,不是网卡真实 MAC**。真实网卡 MAC 由 `DeviceMac` 解析,**只用于本地日志**
(`MainActivity.kt:3737`、日志行 `:3776-3779`)。
### 5.4 签名算法
```
sign = md5( 参数字符串 + 密钥 ) // 直接字面拼接,无分隔符
```
* `/i.php`:密钥 `APP_KEY = 024210cba40d4385a93e6c2d3249bfb5`(`SongUrlResolver.kt:36`,`ktv_api.js:20`),
**参数串不含 sign**、顺序见 §5.1。
* `/music/do.php`:密钥 `SDK_KEY = 19042303a8374f67ae3fe1e25c97936f`(`:37`,`ktv_api.js:21`)。
* `md5` 实现:原生 `MessageDigest.getInstance("MD5")` + `"%02x"` 小写十六进制(`SongUrlResolver.kt:436-438`);
JS 手写 MD5(`ktv_api.js:51-107`),同样输出小写 hex。
* ⚠️ `ktv_api.js` 里的 `CONFIG.VER/VN`(`4` / `4.100.5.11121504`,`:22-23`)**不是实际使用的值** ——
桥在构造 `KtvApi` 之前把它们覆写成 `2.0` / `4.1.3.03281430`(`KtvJsBridge.kt:318-319`)。
### 5.5 传输通道(为什么必须"干净请求")
两套实现都走同一段裸 Socket(`KtvJsBridge.NativeBridge.rawRequest` `:628-708`):
* 请求行只发 `Host` + 调用方指定的头(`i.php`/`do.php` 只有 `Accept: */*`),
**不补** UA / `Connection` / `Accept-Encoding`(`:627` 注释)。
* HTTPS 用 `TlsCompat.sslSocketFactory()` + `createSocket(plain, host, port, true)`(`:648-651`,先连接再包 SNI)。
* 连接超时显式设置 `connect(InetSocketAddress(host, port), connectTimeout)`(`:646`、`:656-658`),
`soTimeout = timeoutMs.coerceIn(1000, 30000)`(`:660`)。
* **裸 Socket 失败(status 0)才回退 `HttpURLConnection`**(`request` `:580-589`;回退实现 `httpRequest` `:593-625`,
中性头:`Go-http-client/1.1` / `Accept-Encoding: identity` / `Connection: close`);
两个都失败才 `MiliLog.localError("NET-ERR ...")`(`:587`)。
裸 Socket 异常打 `MiliLog.warn("RAW-ERR ...")`(`:700-703`,**这条进远程日志**)。
* ⚠️ 这条通道**不受** `network_security_config` 与 `HttpsURLConnection` 默认工厂影响(注释 `:637-639`)。
---
## 6. 地址可信性判定(唯一闸门 `AdVideoFilter.isTrustedSongUrl`)
### 6.1 规则清单(按判定顺序)
`AdVideoFilter.isTrustedSongUrl(url, musicno, allowedHosts)` `:131-151`:
| 序 | 判定 | 不通过时 | 行号 |
|---|---|---|---|
| 1 | `url` 非空 | 拒 | `:132` |
| 2 | 必须以 `http://` 或 `https://` 开头 | 拒 | `:133` |
| 3 | **`isAdVideo(url)` == false**(黑名单,**优先级最高**,命中允许清单也要拒) | 拒 | `:134` |
| 4 | `hostOf(url)` 能取到 host | 拒 | `:135` |
| 5 | **裸 IP**(`IP_HOST` = `^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$`,`:200`)**且不在允许清单** | 拒 | `:146` |
| 6 | **`pub-*.r2.dev` + path 含 `/ss/`**(`R2_HOST` `:203`,**不受允许清单影响**) | 拒 | `:147` |
| 7 | path 里的 **`.ts` 文件名主体必须等于 `musicno`**(`musicno` 为空时**跳过**该规则) | 拒 | `:148-149` |
黑名单明细:
* `AD_MARKERS`(`host + path` 上做小写 `contains`,`:26-33`):`"/ad_files/"`、`"my_ad_video"`、`"wb66"`、`"demo"`。
* `AD_HOSTS`(精确或子域匹配,`:43-46`):`"gz.ac16.vip"`、`"ac16.vip"`。
* `isAdVideo`(`:97-105`):`host` 取不到时**退化为整串匹配**(`:101`,宁可拦下也不放行广告)。
允许清单:
* `allowedMediaHosts`(`@Volatile var`,`:69`,默认 `md.kasugano.cn` + `192.168.100.232`;
设置项经 `setAllowedMediaHosts()` `:73` 写入)。
* `isAllowedMediaHost(host)`(`:84-88`):精确匹配或**子域**匹配(`value.endsWith(".$it")`)。
* 作用范围:**只豁免第 5 条(裸 IP)**。第 3、6、7 条不受影响。
`.ts` 文件名解析:`tsBasename(path)`(`:193`)用 `TS_BASENAME = Regex("/([^/]+)\\.ts(?:/|$)", IGNORE_CASE)`(`:197`)。
path 由 `pathOf()`(`:178`)取得,**已剥离 query 与 fragment**;host 由 `hostOf()`(`:160`)取得,
`java.net.URI` 失败时手工正则兜底(`:163-168`)。`hostOf`/`pathOf` 的剥离是硬性要求:
真源地址形如 `…/480p/7200088.ts?token=…&t=…`(`:120-123` 注释),拿整串判定会漏放或误杀。
### 6.2 「四处闸门同口径」的具体位置
| # | 路径 | 闸门代码 | 位置 |
|---|---|---|---|
| 1 | **原生** | `SongUrlResolver.isValidSongUrl()` → `AdVideoFilter.isTrustedSongUrl(url, musicno)` | `SongUrlResolver.kt:433-434`(调用点 `:266`) |
| 2 | **KtvJsBridge(JS 回来之后)** | `complete()` → `AdVideoFilter.resolve(url)`(= `isAdVideo`,广告返回 null)→ 再 `isTrustedDownloadUrl(url)` | `KtvJsBridge.kt:391-394`;`resolve` `AdVideoFilter.kt:210`;`isTrustedDownloadUrl` `KtvJsBridge.kt:368-389` |
| 3 | **SongApiClient(DB 回落)** | `fetchFromDb()` → `!isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)` | `SongApiClient.kt:153`;`isLikelyDemoUrl` `:171-179` |
| 4 | **JS** | `isDemoUrl(url, musicno)` | `ktv_api.js:174-197`(调用点 `:472`、`:492`、`:500`) |
| 5 | (**最终兜底**,非"四路径"之一) | `SongOkDownloadManager.buildDownloadUrl`:缓存地址 `:257`、新取地址 `:276` | `SongOkDownloadManager.kt:248-298` |
第 2 处多一层 **CDN 域名后缀白名单**,这是其它三处没有的:
`trustedHostSuffixes = ["ktvsky.com","origjoy.com","cherryonline.cn","ac16.vip","ktvdaren.com","j-make.cn"]`
(`KtvJsBridge.kt:359-362`);host 既不在后缀白名单、又不在允许清单 → 判不可信(`:386-388`)。
自建源媒体域名(`md.kasugano.cn` / `192.168.100.232`)**正是靠允许清单**通过这一层(`:381`)。
服务端同口径实现(对端参考):`selfhost-source/server/ktv/resolver.py:121 is_trusted_url(url, tid)`。
### 6.3 优先级总结
```
非 http(s) → 拒(最高)
广告黑名单(wb66 / demo / ac16.vip / ad_files / my_ad_video @ host+path)→ 拒(命中允许清单也拒)
R2 测试桶(pub-*.r2.dev + path 含 /ss/)→ 拒(命中允许清单也拒)
裸 IP → 拒,除非 host 命中 allowedMediaHosts(精确或子域)
.ts 文件名 != musicno → 拒(musicno 为空则跳过)
其余 → 放行
```
判定失败的处理:`SongOkDownloadManager.buildDownloadUrl` 的不可信分支(`:282-289`)会
`purgeCachedSongFile(song, "untrusted-resolved-url")`(`:301-315`,删除已落盘的歌曲文件 +
`SongFileValidator.forget`)并上报 `MiliLog.error("点歌编号=… 状态=失败 详情=地址不可信(广告或文件名不符) url=…")`。
缓存地址不可信时也删文件(`:261-264`,理由见 `:258-260`:广告能通过内容校验,一旦落盘会被 `isDownloaded()` 永久复用)。
---
## 7. 失败 / 回落矩阵
约定:**官方源** = `http://zk.ktv1.top:6677`;**自建源** = `https://mdmuse.kasugano.cn`(默认排第一)。
"某源失败" = `demote(host)` 后换下一个源;"回落" = 上一级路径返回 null 后交给下一级。
| 情形 | 官方源表现 | 自建源表现 | App 动作 |
|---|---|---|---|
| `code:200` + 地址可信(`.ts` 名 == 编号、域名/CDN 合法) | `msg=SUCCESS`、真源 CDN(`txog.ktvsky.com` / `download.origjoy.com`) | `msg=SUCCESS`、`data` 指向 `md.kasugano.cn:4433/media/<编号>.ts`(或内网裸 IP) | `promoteSource`/`promote` 该源 → `lastHost/报告源` 记录 → 原生解除冷却(`blockedUntil=0` `SongUrlResolver.kt:269`) → `Eac19WindowGuard.noteUrlOk()`(`SongApiClient.kt:131`) → 推送一次 → 最终闸门通过 → 落盘下载 |
| **业务码 403**(HTTP 200 + `{"code":403,"msg":"请求被拒绝:检测到异常行为"}`) | 历史形态 | 服务端默认不会(用 404 表达未就绪) | JS:`needRetry` → `sleep(3000)` → 同 token 重试**一次**(`ktv_api.js:482-486`)→ 仍 403 → demote。原生:`code==403` → `sawForbidden=true`(`SongUrlResolver.kt:373`)→ 判失败 → demote。**全部源失败且 sawForbidden** → 3 分钟冷却(`BLOCKED_COOLDOWN_MS` `:47`)+ `MiliLog.localError("SOURCE-BLOCKED 源站返回 HTTP 403…")` + `Eac19WindowGuard.noteRiskBlocked()`(**只写日志、无弹窗**)+ `noteAllSourcesFailed()` |
| **HTTP 403**(状态码本身) | 实测主源 `/i.php` 会直接 403 | 不会 | `KtvJsBridge.request`:`MiliLog.localError("HTTP-403 …")`,且 URL 含 `/i.php` 或 `/music/do.php` → `Eac19WindowGuard.noteRiskBlocked()`(`KtvJsBridge.kt:568-577`)→ raw 返回 JS → 该源判失败 → demote。原生 `callEnvelope`:`status==403 → sawForbidden`(`SongUrlResolver.kt:398`) |
| **业务码 404**(`{"code":404,"msg":"not in library"}` / `not ready: pending`) | — | **服务端约定用它表达"这首还没下载完成"**(`service.py:28`、`:90-96`) | 一律当"该源没地址":`code!=200` → 同上重试一次 → demote → 换源;**不会**触发 `sawForbidden`,也**不**弹"换 IP" |
| **广告/错号地址**(`code:200` 但 `isDemoUrl` 命中:`wb66` / 广告域名 / `.ts` 名 ≠ 编号) | 出口 IP 授权窗口失效时的**新形态**:对每首歌都返回同一段广告 | 不应发生 | JS:`needRetry=true`(`ktv_api.js:472-475`)→ 3 秒退避重试一次 → 仍广告 → demote。全部源都只给广告(原生统计 `adOnlyCount>0 && otherFailCount==0`,`SongUrlResolver.kt:296-304`)→ 3 分钟冷却 + `MiliLog.error("SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源(该出口 IP 授权窗口失效)")` + `noteRiskBlocked()` + `noteAllSourcesFailed()` |
| **地址不可信但拿到了 URL**(最终闸门拒收) | 同广告行 | — | `SongOkDownloadManager`:`MiliLog.error("… 状态=失败 详情=地址不可信(广告或文件名不符) url=…")` + `purgeCachedSongFile` → 下载返回空 → `onDownloadFailed("无法生成下载地址")` |
| **超时** | — | — | 单次请求:JS 默认 8000ms(`ktv_api.js:231`);原生 `TIMEOUT_MS = 8000`(`SongUrlResolver.kt:40`);裸 Socket 连接/读取各 `coerceIn(1000, 30000)`(`KtvJsBridge.kt:635`、`:660`)。JS 整体受桥注入的 180 秒兜底(`JS_REQUEST_TIMEOUT_MS` `KtvJsBridge.kt:48`,`setTimeout → finish('')` `:312`);`SongApiClient` 等 185 秒(`:122`)。超时 → 该源失败 → demote → 换源;全部失败 → 回落原生 → 回落 DB → 「无法生成下载地址」 |
| **HTTP 5xx** | — | — | `MiliLog.localError("HTTP-5xx …")`(`KtvJsBridge.kt:568-570`,**不**触发风控提示)→ JS 判失败 → 重试一次 → demote;原生 `callEnvelope` 记 `localWarn` → null |
| **令牌 401**(带 `X-App-Token` 被拒) | 官方源没有 `/token`(404/405)→ **从不带该头** | `/token` 正常;令牌过期 → `{"ok":false,"error":"令牌无效或已过期"}` + 401 | 原生:`AppTokenClient.withToken` 判定 → `invalidate` → 重取 → **再发一次,只重试一次**(`AppToken.kt:195-201`)。JS:`tokenRejected` → `invalidateAppToken(host)` → 重取头 → **不 sleep** 立即重试一次(`ktv_api.js:469-486`)。第二次仍 401 → 该源判失败 → demote |
| **`/token` 404/405** | 命中(官方源) | 不会 | 记住"该源不支持令牌"(进程内**永久**)→ 之后不再尝试、也不带该头(`AppToken.kt:141-146`);请求照常发(服务端 `app_token_enforce=false`) |
| **`/token` 其它失败**(网络/5xx/解析不出) | — | 临时抖动 | 60 秒冷却(`AppToken.FAILURE_BACKOFF_MS`)→ 期间不带令牌发请求;冷却后自动重试 |
| **网络不可达**(裸 Socket 与 HttpURLConnection 都失败) | — | — | `MiliLog.localError("NET-ERR …")` → 该源失败 → … → 全部失败 → 「无法生成下载地址」;下载层把它记进 `networkFailed`(`SongOkDownloadManager.kt:387-390`、`isNetworkFailure` `:234-246`),网络恢复后 `retryNetworkFailed()`(`:220`,最多 8 首) |
| **全部源彻底失败** | — | — | `SongUrlResolver.resolve` 末尾**无条件**调 `Eac19WindowGuard.noteAllSourcesFailed()`(`:318`)→ `onAllFailed` 弹一句**「远程源已全部失效,等待新版本修复」**,15 分钟节流(`Eac19WindowGuard.kt:42`、`:73-84`)。**注意**:刚进入 `blockedUntil` 冷却时的下一次取址会在 `:227-232` 直接返回 null,**不会**再弹(弹窗在进入冷却那一次已发) |
| **下载中断/卡死/文件异常** | — | — | 重试链:`MAX_RETRY_COUNT = 3`(`:23`)、`STALL_TIMEOUT_MS = 25_000`(`:24`,看门狗 `:489-507`)、退避 `attempt*750ms` 上限 2250ms(`:525`)。每次重试先 `song.downloadUrl = null` + `SongApiClient.clearTokenCache()`(`:522-523`,会重置 JS 会话、回到默认源顺序)再重新取址。落盘校验:`MIN_VALID_FILE_SIZE`(`:414`)、`TsDecryptor.isEncrypted`(`:420`)、`SongFileValidator.inspect`(`:432`,失败提示"服务器返回试听片段(N 秒),未保存也未加入已点" `:442-445`) |
| **用户可见提示** | — | — | 只有一句(`Eac19WindowGuard.onAllFailed`);风控 403 只写日志(原「歌曲源临时受限…换 IP」弹窗已按用户要求删除,`Eac19WindowGuard.kt:36-37`) |
---
## 8. 点歌推送(App → 自建服务器 `/push_download`)
### 8.1 时机与目标
| 项 | 值 / 位置 |
|---|---|
| 时机 | **拿到下载地址之后**,在汇合点里:`SongApiClient.getSongDownloadUrl` 4 参版 `:72-79`,条件 `!url.isNullOrEmpty() && musicno.isNotBlank()`;`musicno` 会 `trim()`(`:75`) |
| 调用 | `PushDownloadClient.offer(musicno, url, songName)` `PushDownloadClient.kt:33`(fire-and-forget,异常只 `Log.w` `:76`) |
| 目标(默认规则) | `PushDownload.effectiveServerUrl(configured, sourceHosts)` `PushDownload.kt:58-65`:① 显式非空且不是 `DISABLED_VALUES` → 原样用;② 显式 `off`/`none`/`no`/`0`/`false`/`关闭`/`禁用`(`:42`)→ **关闭**;③ **留空 → 若 `sourceHosts` 第一项是自建源(`SourceHosts.isSelfHost` `:118`,即主机名等于 `mdmuse.kasugano.cn` 或 `192.168.100.232`)就用它,否则关闭** |
| 端点 | `endpoint(serverUrl)` `PushDownload.kt:73-78` = `<去掉尾部 />/push_download`;**空串/非 http(s) → 返回 null → 连线程都不起**(`PushDownloadClient.kt:52`) |
| 请求方式 | `POST`,`Content-Type: application/x-www-form-urlencoded; charset=utf-8`,`User-Agent: MaidongKTV/1.2`,连接+读取超时各 **8000ms**(`PushDownloadClient.kt:75-104`,`PushDownload.TIMEOUT_MS` `:31`) |
| 线程 | 独立守护线程 `"push-download"`(`PushDownloadClient.kt:37-43`),每次现读设置(`KtvStore().load()` `:48-49`,保证设置页改完立刻生效) |
| 鉴权 | body 里的 `token`(`store.pushToken`,默认 `"local"`,`KtvStore.kt:78`/`:309`)+ 自建目标额外带 **`X-App-Token`**(`:86`,经 `AppTokenClient.withToken(host)` `:61`,host = `SourceHosts.hostOf(serverUrl)` `:53`) |
### 8.2 body 字段(`PushDownload.buildForm` `:87-93`)
| 字段 | 值 |
|---|---|
| `tid` | 点歌编号(`musicno.trim().toLongOrNull()`,必须 `> 0`,否则**直接放弃**,`PushDownloadClient.kt:34`) |
| `url` | 刚拿到的媒体地址,**整体 `URLEncoder.encode`**(`:90`;注释强调不编码会被服务端表单解析拆成多个字段,`:83-85`) |
| `name` | 歌名(可选,仅用于服务器日志核对,**不入库**) |
| `token` | `pushToken`(空串时回落 `"local"`,`:88`) |
### 8.3 去重与失败处理
* **5 分钟内存去重**:`DEDUPE_MS = 5 * 60_000`(`:28`);`shouldSkip(tid, now)` `:97-100`;
key 是 `tid.toString()`,表上限 `MAX_REMEMBERED = 512`(`:34`),超限时按 `now - DEDUPE_MS*4` 清旧(`:107-113`)。
* 去重检查在**两处**:`offer` 里先廉价短路(`PushDownloadClient.kt:36`,不起线程),
`submit` 里再判一次(`PushDownload.kt:139`)。
* **`markPushed` 在 `sender.send` 之前执行**(`:140-141`)→ 同一编号 5 分钟内**失败也不重试**。
* **失败只写本地日志**:`MiliLog.localWarn("PUSH-DL 推送失败 端点=… HTTP …")` `:95` /
`MiliLog.localWarn("PUSH-DL 推送失败 端点=… <异常>")` `:99`;成功也只写本地
`MiliLog.local("PUSH-DL 已推送 编号=… 服务器=…")` `:67`。**绝不弹提示、不阻塞、不改返回值**。
* `submit` 的 `sender` 抛异常被吞(`PushDownload.kt:141-145` 返回 false)。
* 与令牌的关系:令牌拿不到就"不带 `X-App-Token` 按老形态推"(服务端也接受 body 的 `push_token`,
`pushdl.py:213-217`);令牌被拒(401)时 `AppTokenClient.withToken` 会清缓存重取、**只重试一次**。
* 服务端行为(对端参考 `pushdl.py:204-264`):① 鉴权(`X-App-Token` **或** `push_token`);
② 同 IP 每分钟限流(超限 429 + `Retry-After`);③ `tid` 必须正整数;④ `url` 必须 http(s)
且过 `is_trusted_url(url, tid)` 闸门;⑤ 去重三态 `already_done|already_queued|recently_pushed`;
⑥ 接受则 `push_enqueue(tid, url)`,`name` 只写事件。
---
## 9. JS 与原生一致性
### 9.1 `KTV_BRIDGE_API` 版本号的作用
| 位置 | 值/用法 |
|---|---|
| `ktv_api.js:15` | 注释标记 `// KTV_BRIDGE_API: 14 (Android 缓存校验标记,请勿删除)` |
| `ktv_api.js:535` | `global.KtvBridgeApiVersion = 14`(供 JS 侧/调试读取) |
| `KtvJsBridge.kt:403` | `isValidScript()` 要求缓存文件同时包含 `"KtvApi"` 且包含 `"KTV_BRIDGE_API: 14"`(长度还必须在 1001..500000 字节) |
| `KtvJsBridge.kt:427-430` | `updateCache()` 下载远端脚本后 `require(script.contains("KTV_BRIDGE_API: 14"))`,否则抛错、删临时文件、**保留旧脚本** |
作用:**版本闸门**。远端热更新脚本若不兼容(标记不等于 14)→ 拒绝落地,
设备继续用已有缓存/内置脚本。改 `ktv_api.js` 的取址协议时必须同步提升这里的数字(两处)。
### 9.2 `KtvApiConfig` 注入了哪些字段
只在 `if (!window.api)` 分支里注入一次(`KtvJsBridge.kt:314-331`,取址时执行):
| 字段 | 注入值 | 位置 |
|---|---|---|
| `VER` | `'2.0'` | `:318` |
| `VN` | `'4.1.3.03281430'` | `:319` |
| `HOSTS` | `sourceHostsForJs()`(设置项 `sourceHosts`,空则 `SourceHosts.DEFAULT`) | `:321`(读取 `:256-260`) |
| `HOST` | `hosts.firstOrNull()` | `:322`(**JS 侧从未读取该字段**,见 §11) |
| `ALLOWED_MEDIA_HOSTS` | `allowedMediaHostsForJs()`(设置项,空则默认清单) | `:323`(读取 `:263-267`) |
构造:`new window.KtvApi({mac: DEVICE_MAC, sn: DEVICE_SN, maxDeviceRetries: 0, debug: true})`(`:325`);
并**包一层** `regenerateDevice`,防止 JS 自己换设备身份(`:326-331`)。
**未注入** `APP_ID` / `APP_KEY` / `SDK_KEY` —— 它们沿用 `ktv_api.js` 自带值(当前与 Kotlin 侧一致);
也**未注入** `MWS` / `RSA_PUBKEY`。
`debug: true` → `KtvApi._log()` 走 `console.log`(`ktv_api.js:294`),
被 `wrapHtml` 注入的 console 桥转发到 `android.log`(`KtvJsBridge.kt:478-484`)→
`NativeBridge.log` → `MiliLog.local("JS " + message)`(`:493-498`,**只写本地**)。
### 9.3 脚本来源与热更新回退链
1. 优先用缓存 `filesDir/ktv_api_cache.js`(`CACHE_FILE` `:43`),仅当 `isValidScript` 通过才用(`:110-112`)。
2. 否则用打包内置 `assets/mobile/ktv_api.js`(`readAssetScript` `:396`)。
3. `updateCache`(`:408-451`)在后台线程拉远端
`https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js`
(`REMOTE_JS_URL` `:45-46`,`UA: KTV-Updater/2.0`,连接 10s / 读取 15s,`≤2MB`),
校验通过后原子替换;若当前空闲(`ready && callbacks.isEmpty() && !destroyed`)立即热重载(`:438-443`),
否则**下次启动生效**。
4. 缓存脚本加载后**没有导出** `window.KtvApi` → **回退内置 asset 一次**(`:173-177`,`assetRetryAttempted` 防重入)。
5. 内置 asset 也没导出 → `markUnavailable("KtvApi 未导出")`(`:182`)→ 之后取址**直接走原生通道**,
不再每次白等 15 秒(`SongApiClient.kt:96-98`)。
6. 看门狗:`BRIDGE_INIT_TIMEOUT_MS = 20_000`(`:50`)内 `onPageFinished` 未回调或未导出 →
`markUnavailable("初始化超时:onPageFinished 未回调或 KtvApi 未导出")`(`:116-121`)。
7. WebView 构造抛异常 → `markUnavailable("WebView 初始化失败: …")`(`:122-126`)。
8. 桥**后来**就绪(热重载成功等)会**自动清除** `unavailable` 并上报一条
`JS-BRIDGE 已恢复可用(<原因> 已解除)`(`:164-169`)。
其它一致性约定:
* 两套实现读**同一份设置**(`KtvStore.sourceHosts` / `allowedMediaHosts`),默认值逐项一致。
* 两套实现走**同一条**裸 Socket 通道(原生用 `KtvJsBridge.rawCleanGet(rawCleanPost)`)。
* 两套实现的**风控表现一致**:都只发 `Host` + `Accept`(`/i.php`、`/music/do.php`)。
* **原生是兜底、JS 是主路径**:源站配置变更时 JS 能热更新,而 `SongUrlResolver` 里的
`APP_ID/APP_KEY/SDK_KEY/VER/VN` 是**编译进 APK 的镜像**,必须发版才能改(`SongUrlResolver.kt:26-28`)。
* `wrapHtml`(`:454-489`)额外注入诊断脚本:上报 WebView UA 与 `async/await` 支持(`:472-477`)、
`window.onerror`(`:466-469`)、`console.log` 桥(`:478-484`)。
---
## 10. 可核对清单(How to verify,8~12 条)
| # | 验证项 | 怎么做 | 期望 |
|---|---|---|---|
| 1 | 默认源列表 | 看 App「设置 → 歌曲源」文本框 / `<存储根>/state.json` 的 `sourceHosts` | `http://zk.ktv1.top:6677` 在前(官方源优先)、`https://mdmuse.kasugano.cn` 在后(兜底) |
| 2 | 源列表立刻换源(原生) | 设置里只留一个错误源(如 `http://127.0.0.1:1`)保存 → 点一首未下载的歌 | 日志出现 `SOURCE-HOSTS 歌曲源已更新(1 个): …`(`SongUrlResolver.kt:147`,本地日志)+ `NATIVE-SONG 全部源失败 …` |
| 3 | JS 路径是否真的按新源走 | 同上改设置后**不重启**,看本地日志是否仍出现旧源 `Try source (1/N): …` | ⚠️ 仍会用旧顺序(需 `resetApiSession` 或重启才生效,见 §3.2 注) |
| 4 | 令牌接口(自建源) | 服务器上 `curl -s http://<自建源>/token/status`(或 `GET /api/client_ip`) | `live_tokens`/`pending_log` 有签发计数;App 本地日志有 `APP-TOKEN …` 行 |
| 5 | 官方源"不支持令牌"记忆 | 把 `sourceHosts` 设成官方源打头,点一首歌,再看本地日志 | 一条 `APP-TOKEN 源不支持令牌接口(HTTP 404/405),之后不再尝试 source=…`,且后续点歌不再请求 `/token` |
| 6 | 令牌 401 只重试一次 | 单测:`app/src/test/java/com/local/ktv/AppTokenTest.java` 的 `unauthorizedClearsCacheRefetchesAndRetriesExactlyOnce` / `unauthorizedRetryHappensOnlyOnceEvenIfStillRejected` | 通过(`fetchCount` 断言只多一次) |
| 7 | 地址闸门四处口径 | 跑单测 `AdVideoFilterTest`、`MediaHostAllowlistTest`、`SourceHostsTest` | 全绿(含带 query/fragment 真源放行、广告仍拒、黑名单压过允许清单) |
| 8 | 广告地址被拦后的行为 | 本地日志搜 `SONG-AD 丢弃不可信缓存地址` / `SONG-AD 清除可疑歌曲缓存` / 远程日志搜 `状态=失败 详情=地址不可信` | 地址被清、已落盘文件被删(`purgeCachedSongFile`),下次点歌重新取址 |
| 9 | 全部源失效提示 | 断网或让出口 IP 进入受限态后点歌 | 远程日志一条 `NATIVE-SONG 全部源失败 编号=… 详情=token-fail\|no-url\|demo-or-mismatch 用时=…ms`(`SongUrlResolver.kt:307`)+ 界面弹「远程源已全部失效,等待新版本修复」(15 分钟节流) |
| 10 | 断网 / 恢复表现 | 断开网络 → 播放页右上角角标「网络未连接」;恢复后远程日志 | `NET-DOWN 设备网络已断开` → `NET-UP 设备网络已恢复, 重建歌曲源会话` → `NET-REFRESH reason=net-up retried=N`(`MainActivity.kt:3799`/`:3813`/`:3835`) |
| 11 | 点歌推送(自建目标) | 服务器 `curl -s <自建源>/push_download/status`;App 本地日志搜 `PUSH-DL` | `accepted`/`duplicate` 计数增长;App 本地一条 `PUSH-DL 已推送 编号=… 服务器=…`。填 `off` 后应无任何 `PUSH-DL` 行 |
| 12 | 点歌推送去重 | 5 分钟内反复点同一首(或反复取址同一编号) | 只推一次(`PushDownloadTest.dedupeSameTidWithinFiveMinutes`;`shouldSkip` `:97`) |
| 13 | 热更新脚本不兼容时回退 | 把 Gitea 上 `ktv_api.js` 的 `KTV_BRIDGE_API: 16` 改成别的值后重启 App | 远端脚本被拒(logcat `JS update failed: Invalid or incompatible JS file`),仍按缓存/内置脚本运行 |
| 14 | WebView 不可用时的原生兜底 | 真机日志搜 `JS-BRIDGE` / `NATIVE-SONG` | 有 `JS-BRIDGE 判定本机 WebView 不可用(…),后续点歌直接走原生取址通道`,之后 `NATIVE-SONG 取址成功 编号=… 源=… urlHost=…`(远程日志) |
| 15 | 本地日志文件 | `<存储根>/logs/ktv.log`(启动行 `APP-INIT … logFile=<路径>`) | 逐源/逐次细节(`JS …`、`Try source`、`APP-TOKEN`)都在这里;远程只有"结论"一条 |
---
## 11. 与预期不一致 / 值得注意的点(阅读代码时发现)
1. **`ktv_api.js` 的 `CONFIG.VN` / `CONFIG.VER` 是"死值"**:文件里写的是 `'4.100.5.11121504'` / `'4'`
(`:22-23`),实际取址用的是桥覆写后的 `'4.1.3.03281430'` / `'2.0'`(`KtvJsBridge.kt:318-319`)。
直接读 JS 文件会得出错误的协议版本结论。
2. **`CONFIG.HOST` 被注入但从未被读取**:`KtvJsBridge.kt:322` 写了它,`ktv_api.js` 全文只用到
`CONFIG.HOSTS`(`getToken` 的默认值用 `CONFIG.HOSTS[0]`,`:358`)。属于无害的冗余字段。
3. **设置页改"歌曲源"对 JS 主路径不立即生效**:注入代码在 `if (!window.api)` 里,
设置页保存只调 `applySourceSettings()`(`MainActivity.kt:9451`),不重置会话。
已构造的 `window.api.sourceOrder`(构造时快照)仍生效,直到重启/断网恢复/下载重试触发 `resetApiSession()`。
原生兜底路径则**立刻**生效 —— 两条路径在同一时刻可能用**不同的源顺序**。
4. **业务码 403(HTTP 200)在 JS 路径不触发风控提示**:`KtvJsBridge.request` 只在 `status == 403`
时调 `Eac19WindowGuard.noteRiskBlocked()`(`:575-577`);JS 侧看到 `{"code":403}`(HTTP 200)
时只是"该源失败 + 退避重试一次"。**只有原生** `SongUrlResolver` 会在业务码 403 上置 `sawForbidden`
(`:332`、`:373`)。所以"业务码 403 风控"这个信号在 JS 路径上是丢失的(远程日志里只能看到 `点歌失败 … 详情=do.php code=403 …`)。
5. **401 重试确实只有一次,两套实现口径一致但细节不同**:原生 `AppTokenSession.withToken` 第二次结果
直接返回(`AppToken.kt:200`);JS 侧 `tokenRejected` 分支**跳过 3 秒退避**立即重试(`ktv_api.js:478-481`),
而风控/广告场景会 `sleep(3000)` 再重试(`:483-485`)。
6. **`AppTokenSession` 的"永久不支持"只是进程内记忆**:`unsupported` 是普通 `HashSet`
(`AppToken.kt:110`),**不落盘**,App 重启后会重新探测一次 `/token`。文档/注释里的"永久"= 本次进程生命周期。
7. **`KtvJsBridge.isTrustedDownloadUrl` 多一层 CDN 域名后缀白名单**,且白名单里**含 `ac16.vip`**
(`KtvJsBridge.kt:360`)—— 也就是广告域名。它之所以没造成事故,仅仅因为 `complete()` 先调
`AdVideoFilter.resolve()`(`isAdVideo` → `AD_HOSTS` 含 `ac16.vip`)把广告拦掉了(`:392-394`)。
这是一个**依赖调用顺序的安全网**:如果哪天有人把 `resolve()` 挪到 `isTrustedDownloadUrl` 之后,
`ac16.vip` 的广告就会被这条白名单放行。
8. **`SongOkDownloadManager` 用 `song.filename` 推编号;缺 `filename` 时退化成 `song.id`**
(`:253-254`)。`song.id` 在曲库条目里可能是数据库主键,这个值会一路传给
`SongApiClient`(闸门的 `musicno`)与点歌推送的 `tid`:非数字时推送直接放弃,
数字但与 `.ts` 文件名不符时会把**正常地址误杀**成"文件名不符"。代码里只用它做"下载层最终闸门"的
参数(`:276` 传的是 `musicNoFromFile`,可能为 null → 跳过文件名规则),但 `getSongDownloadUrl(musicNo.orEmpty(), …)`
(`:269`)传的是**带回退的** `musicNo` → DB 回落路径的闸门仍会用这个可能有歧义的值。
9. **`streamDownloadAndPlay`(`MainActivity.kt:5232`)是死代码**:全仓 grep 只有定义与一处注释,
**没有任何调用点**;实际"点歌即下即播"走的是 `startDownloadAndPlay` → `downloadSong` →
`SongOkDownloadManager`。它内部还留着一条**不过最终闸门**的 `getSongDownloadUrl` 调用(`:5239`)
与 `ThunderSDK/4.1.3` 的 UA、`.tmp` 临时文件写法;如果将来复活它,需要补闸门。
10. **`MainActivity.download(song)`(`:4984`)完全不取址、不过闸门**:直接用 `song.downloadUrl`/`videoUrl`
下载。它是"旧版备用"路径,若被曲库条目(`Song.remote()` 会填 `downloadUrl`)触发,
就绕过了 `AdVideoFilter`。这是四条主路径之外的**第五条消费点**。
11. **DB 回落路径静默吞异常**:`fetchFromDb` 的 `catch (_: Exception) {}`(`SongApiClient.kt:157`)
不写任何日志 —— 数据库打不开/查询异常时,外观与"DB 里没有 cloud_url"完全一样。
12. **`ensureConfig` 探测的是"当前队首"**(`SongUrlResolver.kt:239` 传 `allHosts.firstOrNull()`),
而队首会被 `promote/demote` 改变;加上 30 分钟 TTL,实际探测对象在一次会话里可能不是固定那个源。
按当前默认顺序(自建源在首)它返回 `trial_times=0` → 不限制源数量;若把带 `trial_times=2` 的
官方源放首位,则**一次取址只会试 2 个源**(`:240-244`)。
13. **`AGENTS.md` 与代码有轻微漂移**:文档里写 `KTV_BRIDGE_API` 为 **13**("已升到 13"),
代码与 `ktv_api.js` 实际是 **14**(`ktv_api.js:15`/`:535`、`KtvJsBridge.kt:403`/`:429`)。
14. **`AD_MARKERS` 现在含 `demo`**(`AdVideoFilter.kt:32`),只因为匹配范围已收敛到 `host + path`
(不再搜 query)。若将来有人把匹配范围改回整串,`?token=…&t=…` 里的随机串会误杀真源。
15. **`SongUrlResolver.DEFAULT_HOSTS` 与 `SourceHosts.DEFAULT` 是两份重复常量**(`:62-65` / `:31-34`),
必须手工保持一致(`SourceHosts.DEFAULT` 才是 `parseList` 真正使用的默认值,
`DEFAULT_HOSTS` 只用于 `hostOrder` 初值与安装日志 `:132`)。
16. **`/push_download` 的 `markPushed` 在发送之前**(`PushDownload.kt:140-141`)——
一次失败(网络抖动/服务器 5xx)后,同一编号在 5 分钟内**不会**重推。
---
## 12. 仍未确认的部分
* `do.php` 返回里的 **`limit`** 字段(服务端 `service.py:89` 返回 `2000`)在 App 侧**没有任何消费方**;
其业务语义(是否表示试用次数/时长/并发)未在 App 代码中确认。官方源是否也返回该字段、
以及取值含义,**未在代码中确认**(需要抓包或问服务方)。
* `/i.php` 返回的 `retry` / `ordertype` / `end_date` 字段:App 未读取,真实语义**未在 App 代码中确认**;
`heart_time` 已在 V1.6.3 被当作 token 缓存时长使用(语义仍以"实测 5400 秒"为准)。
* `AdVideoFilter.replacementUrl`(`AdVideoFilter.kt:54`)的运行期赋值点:全仓未发现任何写入,
即恒为 `null`(="屏蔽"而非"替换")。是否有外部入口(如远程配置)**未在代码中确认**。
* 官方源(`zk.ktv1.top:6677`)是否支持 `/token`:代码按 404/405 处理,实测结论**未在本次阅读中验证**。
* `CONFIG.MWS`(`https://mws.cherryonline.cn`)与 `mwsLogin()`(`ktv_api.js:325-353`)在当前取址链路里
**没有被调用**(`KtvApi.init()` 直接 `return true`,`:526-529`);它是否在别处(远程页面/其它 JS)被使用,
**未在本次阅读范围内确认**。
* `SongUrlResolver` 的 `resolution` 是否真的存在非 `720` 的调用方:当前代码里所有调用点都传 `"720"`;
其它入口(如未来的 1080p 设置)**未在代码中确认**。
---
## 13. 点歌延迟优化(V1.6.3/46,2026-10-15)
### 13.1 用户现象与真实原因
**现象**:点歌后要等 **3~5 秒**才发出(真正能取到地址的那次)歌曲源请求。
**真实原因(代码路径 + 127.0.0.1 stub 对照测量)**:关键路径上串了一堆**与"点哪首歌"无关**的
一次性开销,其中最大的一条是 JS 里**终态失败也固定 `sleep(3000)` 再重试一次**:
```js
// ktv_api.js(改前)
var needRetry = data.code !== 200; // 403(风控)/404(没有这首)也算 needRetry
if (needRetry) {
if (tokenRejected) { ... } // 令牌被拒才立即重试
else { await sleep(3000); } // ← 无条件 3 秒;用户实测的"3~5 秒"就在这里
data = await self._fetchSongUrl(...); // 3 秒后才发第二次
}
```
官方源(`zk.ktv1.top:6677`)的试用额度只有 `trial_times: 2 / 5 分钟`(`api.php`),
额度用完后 `do.php` 返回 **HTTP 200 + 业务码 403**(或对每首歌返回同一个广告资源)——
这两条都会让 JS 白等 3 秒再重试一次,然后才 `demoteSource` 换到兜底源。
而 `promoteSource` 会把**成功过的源提到队首**,于是"官方源成功一首 → 下一首又排第一 → 又白等 3 秒"
会反复出现,与"点歌后 3~5 秒"完全吻合。
其余叠加项(每项都是**一次完整往返**,全部排在 `do.php` 之前):
| # | 位置(改前) | 问题 |
|---|---|---|
| 1 | `SongUrlResolver.resolveSourced` → `ensureConfig()` | 每首歌的第一个请求是 `POST api.php`(当前官方节点没有这个接口,白等一次往返) |
| 2 | `SongUrlResolver.fetchToken()` | **没有任何缓存**:每首歌、每个源都重打一次 `/i.php`(弱网下还要算 DNS + TCP + TLS) |
| 3 | `AppTokenClient.tokenFor()` | 懒加载:第一次取址才 `GET /token`(阻塞在 `do.php` 之前);且网络 I/O 在**全局锁**里,一个慢源会阻塞其它源 |
| 4 | `KtvJsBridge.getSongUrl` | 取址前读**两次** `state.json`(`sourceHostsForJs()` + `allowedMediaHostsForJs()` 各一次) |
| 5 | `SongApiClient.resolveSongDownloadUrl` | 桥就绪闸门最长等 15 秒(老盒子首次点歌会等满) |
### 13.2 改了什么
| 措施 | 实现位置 |
|---|---|
| 终态失败不退避/不重试;可重试失败退避 800ms(原 3000ms) | `ktv_api.js` `isTerminalFailure()` / `RETRY_BACKOFF_MS`;`KTV_BRIDGE_API` 15 → **16** |
| 上游 `/i.php` token 按 host 缓存(TTL = `heart_time`,默认 90 分钟,单飞,失败 30s 冷却) | `SourceTokenCache.kt`;`SongUrlResolver.fetchToken/loadUpstreamToken` |
| App 令牌按 host 单飞(网络 I/O 不占全局锁) | `AppToken.kt` `AppTokenSession.tokenFor` |
| 后台预热:JS 会话 + 各源 `/i.php` + `api.php` + DNS/TCP/TLS + 各源 `/token` | `Preheat.kt`(纯逻辑)+ `SourcePreheat.kt`(接线);触发点:`MainActivity.onCreate` / `onResume` / `showSearchPage` / `loadBrowsePage` / `refreshNetworkState` / JS 桥就绪 |
| `api.php` 移出取址关键路径(后台预热,失败仍进 30 分钟负缓存) | `SongUrlResolver.preheatConfig()` |
| 取址路径只读一次 `state.json`;配置注入与 `window.api` 构造收敛到 `apiBootstrapJs()` | `KtvJsBridge.sourceConfigForJs()` / `apiBootstrapJs()` |
| 分阶段耗时日志(只写本地) | `SongFetchTrace.kt`(Kotlin 侧)+ `ktv_api.js` `_timing()`(JS 侧,桥注入 `window.api.__orderAt`) |
**日志形态**(`<存储根>/logs/ktv.log`):
```
FETCH-TIME 编号=7303235 阶段=点歌→取址任务开始(含 io 池排队) 自点歌=12ms
FETCH-TIME 编号=7303235 阶段=闸门(等 JS 桥就绪) 自点歌=14ms 详情=用时=1ms ready=true
FETCH-TIME 编号=7303235 阶段=点歌→i.php请求发出 自点歌=21ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=i.php返回 自点歌=182ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=点歌→do.php请求发出 自点歌=190ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=do.php返回 自点歌=343ms 源=http://zk.ktv1.top:6677
FETCH-TIME 编号=7303235 阶段=点歌→取到地址 自点歌=350ms 源=https://mdmuse.kasugano.cn
```
(上例数字来自 stub 测量,真机数值看现场日志。)
### 13.3 对照测量(127.0.0.1 stub,真实脚本 + 真实 Kotlin 逻辑)
* **JS 路径**:`node maidong-ktv/tools/measure_ktv_js.js`(随仓库提交;用 `assets/mobile/ktv_api.js` 的**真实脚本**,
把 `android.httpGet` / `android.appToken` 换成"记录时间戳 + 同步 sleep(150ms 往返) + 按 host 缓存"的 stub)。
每个场景连点 4 首,**第 1 首**的"点歌 → 拿到地址":
| 场景(官方源首答失败形态) | 优化前 | 优化后 | 优化后 + 预热 |
|---|---|---|---|
| 业务码 403(风控/额度耗尽) | 4064ms | 907ms | **307ms** |
| 业务码 404(该源没有这首) | 4059ms | 905ms | **303ms** |
| 只发广告(code=200 + 广告地址) | 4060ms | 1858ms | **1255ms** |
| 第 3 首(官方源前两首成功、第 3 首起 403) | 3757ms | 602ms | **302ms** |
其中"3 秒白等"在时间线上看得很清楚(`quota2` 第 3 首):
`优化前:0ms do.php(官方) → 3155ms do.php(重试,中间就是 sleep 3000) …`;
`优化后:0ms do.php(官方) → 151ms i.php(兜底源) → 452ms do.php(兜底源)`。
另外 4 首的 `do.php` 请求数在终态失败场景由 **6 → 5**(少了一次毫无意义的重试)。
* **预热成本**:一轮预热 = 2 个源 × (`/i.php` + `/token`) ≈ 4 次往返(stub 150ms → 603ms),
全在后台线程;之后每个触发原因 20 秒节流、缓存命中后不再发请求。
⚠️ **测量的边界**:stub 只能证明"JS 逻辑 + 网络往返"的差异;主线程处理、io 池排队、
WebView `evaluateJavascript` 延迟量不到 —— 那部分由设备端 `FETCH-TIME 阶段=点歌→取址任务开始`
(含 io 池排队)与 `阶段=闸门` 两条日志给出,**不要用 stub 数字外推到真机**。
+172
View File
@@ -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));
})();