点歌延迟优化:终态失败不退避 + 上游 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
+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};