Files
maidong-ktv/app/src/main/assets/mobile/ktv_api.js
T

615 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* KTV Song API - JS Hot Update Module
* ====================================
* 从 Gitea 热更新, App 无需发版即可修复 API 变更
*
* 用法:
* const api = new KtvApi({proxy: 'http://127.0.0.1:7897'})
* const url = await api.getSongUrl('7678785')
* // → http://download.origjoy.com/E/ts/.../7678785.ts?sign=...
*/
(function(global) {
'use strict';
// KTV_BRIDGE_API: 16 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
APP_ID: 'd4eeacc6cec3434fbc8c41608a3056a0',
APP_KEY: '024210cba40d4385a93e6c2d3249bfb5',
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
// ⚠️ 下面两个值与 App 侧**实际使用**的值一致(`KtvJsBridge` 注入时也是这两个)。
// 旧文件里写的是 '4.100.5.11121504' / '4',那是**从不生效的死值** ——
// 被桥覆写后就成了历史遗留,直接读本文件会得出错误的协议版本结论。
// 现在即使不经过桥注入(单独调试本文件)也是同一份协议版本。
VN: '4.1.3.03281430',
VER: '2.0',
// 歌曲源入口:**官方源优先、自建源兜底**(2026-10-14 反转)。
// http://zk.ktv1.top:6677 = 官方源(成功率最稳,先试;成功后的地址会上报自建源);
// https://mdmuse.kasugano.cn = 自建歌曲源兜底(官方源取不到时才用它;它自己给的地址**不上报**)。
// ⚠️ 与 Android 侧 SourceHosts.DEFAULT(SongUrlResolver.DEFAULT_HOSTS 直接引用它)**逐项一致**;
// 运行时由 App 设置项注入覆盖(window.KtvApiConfig.HOSTS / HOST)。
HOSTS: [
'http://zk.ktv1.top:6677',
'https://mdmuse.kasugano.cn',
],
HOST: 'http://zk.ktv1.top:6677',
// 媒体地址允许清单(App 设置项注入覆盖):只放行这些主机的"非域名"限制 ——
// 直连内网自建源时媒体地址就是裸 IP(http://192.168.100.232:6677/media/<编号>.ts)。
// ⚠️ 广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss)优先级更高,命中清单也要拒。
ALLOWED_MEDIA_HOSTS: [
'md.kasugano.cn',
'192.168.100.232',
],
MWS: 'https://mws.cherryonline.cn',
RSA_PUBKEY: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB'
};
// ─── 工具函数 ──────────────────────────────────────
function randomHex(n) {
var s = ''; while (n--) s += '0123456789abcdef'[Math.random()*16|0]; return s;
}
// ─── MD5 ───────────────────────────────────────────
function md5(input) {
function add(a, b) {
var low = (a & 0xffff) + (b & 0xffff);
var high = (a >>> 16) + (b >>> 16) + (low >>> 16);
return (high << 16) | (low & 0xffff);
}
function rol(value, shift) { return (value << shift) | (value >>> (32 - shift)); }
function wordHex(value) {
var out = '';
for (var i = 0; i < 4; i++) out += ('0' + ((value >>> (i * 8)) & 255).toString(16)).slice(-2);
return out;
}
// MD5 works on UTF-8 bytes, then appends 0x80 and the original bit length.
var utf8 = unescape(encodeURIComponent(input));
var bytes = [];
for (var i = 0; i < utf8.length; i++) bytes.push(utf8.charCodeAt(i));
var bitLengthLow = (bytes.length << 3) >>> 0;
var bitLengthHigh = (bytes.length >>> 29) >>> 0;
bytes.push(0x80);
while ((bytes.length % 64) !== 56) bytes.push(0);
for (i = 0; i < 4; i++) bytes.push((bitLengthLow >>> (i * 8)) & 255);
for (i = 0; i < 4; i++) bytes.push((bitLengthHigh >>> (i * 8)) & 255);
var shifts = [
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
5,9,14,20, 5,9,14,20, 5,9,14,20, 5,9,14,20,
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21
];
var constants = [];
for (i = 0; i < 64; i++) constants[i] = (Math.abs(Math.sin(i + 1)) * 4294967296) | 0;
var a0 = 0x67452301, b0 = 0xefcdab89 | 0, c0 = 0x98badcfe | 0, d0 = 0x10325476;
for (var offset = 0; offset < bytes.length; offset += 64) {
var words = [];
for (i = 0; i < 16; i++) {
var at = offset + i * 4;
words[i] = bytes[at] | (bytes[at+1] << 8) | (bytes[at+2] << 16) | (bytes[at+3] << 24);
}
var a = a0, b = b0, c = c0, d = d0;
for (i = 0; i < 64; i++) {
var f, g;
if (i < 16) { f = (b & c) | ((~b) & d); g = i; }
else if (i < 32) { f = (d & b) | ((~d) & c); g = (5 * i + 1) % 16; }
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
else { f = c ^ (b | (~d)); g = (7 * i) % 16; }
var oldD = d;
d = c;
c = b;
b = add(b, rol(add(add(a, f), add(constants[i], words[g])), shifts[i]));
a = oldD;
}
a0 = add(a0, a); b0 = add(b0, b); c0 = add(c0, c); d0 = add(d0, d);
}
return wordHex(a0) + wordHex(b0) + wordHex(c0) + wordHex(d0);
}
// ─── RSA Encrypt ───────────────────────────────────
async function rsaEncrypt(plaintext) {
if (global.android && typeof global.android.rsaEncryptPkcs1 === 'function') {
var encrypted = global.android.rsaEncryptPkcs1(CONFIG.RSA_PUBKEY, plaintext);
if (!encrypted) throw new Error('native RSA encryption failed');
return encrypted;
}
throw new Error('RSA/PKCS1 bridge is unavailable');
}
// ─── HTTP ──────────────────────────────────────────
function sleep(ms) {
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);
return {body: value.body || '', status: Number(value.status || 0)};
}
// 部分节点会在 JSON 前输出 PHP Deprecated HTML,从第一个 "{" 开始容错解析。
function parseJsonBody(body) {
body = String(body || '');
var start = body.indexOf('{');
if (start < 0) throw new Error('JSON object not found');
return JSON.parse(body.slice(start));
}
// 把 URL 拆成 host / path(**剥离 query 与 fragment**)。
// 所有"看路径/看域名"的判定都基于它:真源地址带 `?token=…&t=…`(2026-10-11 抓包),
// 拿整串判定就会出现"带 query 匹配不到文件名"(漏放)或"query 里的随机串被当广告特征"(误杀)。
function splitUrl(url) {
var s = String(url || '');
var head = s.split('#')[0].split('?')[0];
var m = head.match(/^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\/([^\/]*)(\/.*)?$/);
if (!m) return { host: '', path: head };
var host = m[1].split('@').pop();
if (host.charAt(0) === '[') {
host = host.indexOf(']') > 0 ? host.slice(0, host.indexOf(']')) : host;
} else {
host = host.replace(/:\d+$/, '');
}
return { host: host.toLowerCase(), path: m[2] || '' };
}
// 媒体地址允许清单(App 设置项注入;默认见 CONFIG.ALLOWED_MEDIA_HOSTS)。
// 只放行"非域名"限制:直连内网自建源时媒体地址就是裸 IP。**黑名单优先级更高**(见 isDemoUrl 的判定顺序)。
function isMediaHostAllowed(host) {
if (!host) return false;
var list = CONFIG.ALLOWED_MEDIA_HOSTS || [];
for (var i = 0; i < list.length; i++) {
var h = String(list[i] || '').trim().toLowerCase();
if (h && (host === h || host.slice(-(h.length + 1)) === '.' + h)) return true;
}
return false;
}
// 匹配 demo/广告/测试资源 URL 的多种模式:
// 1) wb66 / demo 关键字 (服务端注入广告示例: https://wb66.cn/f/d/mJMSQ/719.ts)
// 2) IPv4 地址 (如 http://154.222.26.244:656/722.ts)
// 3) Cloudflare R2 demo bucket (pub-*.r2.dev/ss/ 通用测试文件)
// 4) 任务4: ts 文件名编号与请求的 musicno 不一致 → 视为广告/错误资源
// (例: 请求 7018878 却返回 /719.ts 或 /689.ts)
// ⚠️ 判定一律在 **host + path** 上做(见 splitUrl),**不搜 query**。
function isDemoUrl(url, musicno) {
var s = String(url || '');
var u = splitUrl(s);
var hostPath = u.host + u.path;
if (/(?:wb66|demo)/i.test(hostPath)) return true;
// 广告视频专用域名(含子域)。2026-09-26 事故:新设备(WebView 正常 → 走本 JS 取址)
// 点歌拿到了 gz.ac16.vip 的广告视频,而它通过了下述所有旧规则 → 被当成歌曲下载播放。
if (/(?:^|\.)ac16\.vip$/i.test(u.host)) return true;
if (/\/ad_files\/|my_ad_video/i.test(u.path)) return true;
// 裸 IP:App 只接受域名 —— **除非该主机命中媒体地址允许清单**(自建源直连内网)。
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(u.host) && !isMediaHostAllowed(u.host)) return true;
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源(黑名单,不受允许清单影响)
if (/^pub-[a-z0-9]+\.r2\.dev$/i.test(u.host) && /\/ss\//i.test(u.path)) return true;
// ts 文件名必须等于 musicno。原版真源 URL 恒为 /<musicno>.ts(已实测两个 CDN 域
// txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此),且**带 query/fragment**。
// ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字
// 会直接漏过 —— 这正是广告漏放的直接原因之一。
// ⚠️ 旧正则收尾写的是 `(?:\?|$)`:遇到 `#fragment` 会**完全匹配不到 → 整条规则被跳过**。
if (musicno) {
var m = u.path.match(/\/([^\/]+)\.ts(?:\/|$)/i);
if (m && m[1] !== String(musicno)) return true;
}
return false;
}
// ─── App 令牌(自建源 GET /token)────────────────────
// 令牌的取/缓存/"该源不支持"记忆/401 重试全在 Android 侧(AppTokenSession,纯 JVM 有单测),
// JS 只做两件事:① 请求前问一次当前源的令牌,有就带上 X-App-Token;② 被拒(401)时让原生清缓存。
// 官方源不认 /token(404/405)时这里恒为空串 → **一个字节都不多加**,老源站请求形态完全不变。
function appTokenHeader(host) {
try {
if (global.android && typeof global.android.appToken === 'function') {
var token = global.android.appToken(host);
if (token) return {'X-App-Token': token};
}
} catch(e) { /* 令牌取不到不影响取址 */ }
return {};
}
function invalidateAppToken(host) {
try {
if (global.android && typeof global.android.invalidateAppToken === 'function') {
global.android.invalidateAppToken(host);
}
} catch(e) { /* 同上 */ }
}
// 任务3: 点歌结果上报(成功/失败 + 实际命中的源) → Android 侧写入远程日志。
function reportSongResult(ok, host, musicno, detail) {
try {
if (global.android && typeof global.android.reportSongResult === 'function') {
global.android.reportSongResult(ok ? 'ok' : 'fail', host, String(musicno), String(detail || ''));
}
} catch(e) { /* 日志失败不影响点歌 */ }
}
async function httpGet(url, timeout, extraHeaders) {
timeout = timeout || 8000;
// 原 APK 的 native GET 抓包只带 Accept: */*,不伪造来源 IP。
// extraHeaders 只用于自建源的 App 令牌(X-App-Token);官方源为空对象,请求形态不变。
var headers = {'Accept': '*/*'};
if (extraHeaders) {
for (var k in extraHeaders) {
if (Object.prototype.hasOwnProperty.call(extraHeaders, k)) headers[k] = extraHeaders[k];
}
}
if (global.android && typeof global.android.httpGet === 'function') {
return nativeResponse(global.android.httpGet(url, JSON.stringify(headers), timeout));
}
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.timeout = timeout;
for (var name in headers) {
if (Object.prototype.hasOwnProperty.call(headers, name)) xhr.setRequestHeader(name, headers[name]);
}
xhr.onload = function() { resolve({body:xhr.responseText, status:xhr.status}); };
xhr.onerror = function() { reject(new Error('network error')); };
xhr.ontimeout = function() { reject(new Error('timeout')); };
xhr.send();
});
}
async function httpPost(url, body, timeout) {
timeout = timeout || 8000;
var headers = {'Content-Type':'application/json', 'Accept':'*/*', 'User-Agent':'Dalvik/2.1.0'};
if (global.android && typeof global.android.httpPost === 'function') {
return nativeResponse(global.android.httpPost(url, body, JSON.stringify(headers), timeout));
}
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
xhr.timeout = timeout;
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.onload = function() { resolve({body:xhr.responseText, status:xhr.status}); };
xhr.onerror = function() { reject(new Error('network error')); };
xhr.ontimeout = function() { reject(new Error('timeout')); };
xhr.send(body);
});
}
// ─── KtvApi Class ──────────────────────────────────
function KtvApi(options) {
options = options || {};
this.proxy = options.proxy || '';
this.mac = options.mac || randomHex(16);
this.sn = options.sn || randomHex(16);
this.tokens = {};
// 歌曲源自动排序: 维护一份"当前优先级"列表(运行时可变)。
// 某源解析失败时把它挪到队尾, 下次点歌优先尝试更可靠的源。
this.sourceOrder = (options.hosts && options.hosts.length ? options.hosts : CONFIG.HOSTS).slice();
// 最近一次取址**命中的源**(成功时写入;失败/未开始时为空)。
// 供 Android 侧判定"这条地址是否来自官方源"(只有官方源成功才上报自建服务器)——
// 由桥在返回地址之前用 `android.onSongSource(requestId, host)` 显式取走。
this.lastSourceHost = '';
// 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。
this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries))
? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3;
// 兼容旧远程脚本/调试代码读取单 token 的行为。
this.token = '';
this.debug = options.debug || false;
}
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);
if (idx < 0) return;
this.sourceOrder.splice(idx, 1);
this.sourceOrder.push(host);
this._log('Source demoted to end (解析失败): ' + host);
this._log('Source order now: ' + this.sourceOrder.join(' > '));
},
/** 成功解析的源提到队首(最近成功优先; 曾失败的源恢复后自然回到前面)。 */
promoteSource: function(host) {
var idx = this.sourceOrder.indexOf(host);
if (idx <= 0) return;
this.sourceOrder.splice(idx, 1);
this.sourceOrder.unshift(host);
this._log('Source promoted to front (解析成功): ' + host);
this._log('Source order now: ' + this.sourceOrder.join(' > '));
},
regenerateDevice: function() {
this.mac = randomHex(16);
this.sn = randomHex(16);
this.tokens = {};
this.token = '';
this.lastSourceHost = '';
this._log('Device regenerated: ' + this.mac + '_' + this.sn);
},
// 1. MWS Login
mwsLogin: async function() {
var self = this;
var plaintext = JSON.stringify({device_id: self.mac});
self._log('MWS login...');
try {
var encrypted = await rsaEncrypt(plaintext);
var body = JSON.stringify({
encrypted_data: encrypted, ip: '172.16.32.15', dns: '', router: '',
subnet_mask: '255.255.255.0', channel: 'common', mode: 'ott'
});
var resp = await httpPost(CONFIG.MWS + '/mls-api/v1/login', body);
if (resp.status === 200) {
var data = parseJsonBody(resp.body);
if (data.authorized) {
self.mac = data.device_id || self.mac;
self.sn = self.mac;
self.tokens = {};
self.token = '';
self._log('MWS login OK: ' + self.mac);
return true;
}
}
self._log('MWS login FAIL: ' + resp.status);
return false;
} catch(e) {
self._log('MWS login error: ' + e.message);
return false;
}
},
// 2. Get Auth Token
getToken: async function(host, forceRefresh) {
var self = this;
// 默认源:CONFIG.HOST(与 HOSTS[0] 同值,由 Android 侧一致注入/校验)优先,其次 HOSTS[0]。
host = host || CONFIG.HOST || CONFIG.HOSTS[0];
if (!forceRefresh && self.tokens[host]) {
self.token = self.tokens[host];
return self.token;
}
var ts = Math.floor(Date.now() / 1000);
var params = 'appid=' + CONFIG.APP_ID +
'&mac=' + self.mac + '_' + self.sn +
'&sn=' + self.sn +
'&time=' + ts +
'&ver=' + CONFIG.VER + '&vn=' + CONFIG.VN;
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) {
self.tokens[host] = data.token;
self.token = data.token;
self._log('Token OK: ' + host);
return self.token;
}
}
delete self.tokens[host];
self._log('Token FAIL: ' + host + ', HTTP ' + resp.status);
return null;
} catch(e) {
delete self.tokens[host];
self._log('Token error: ' + host + ', ' + e.message);
return null;
}
},
clearToken: function(host) {
delete this.tokens[host];
this.token = '';
},
_fetchSongUrl: async function(host, musicno, token, ls, resolution, h265, appHeaders) {
var ts = Math.floor(Date.now() / 1000);
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 保持一致(**一个字都不能改**)。
var params = 'appid=' + CONFIG.APP_ID +
'&device=' + this.mac + '_' + this.sn +
'&ish265=' + (h265 ? '1' : '0') +
'&ls=' + ls +
'&musicno=' + musicno +
'&resolution=' + resolution +
'&sn=' + this.sn +
'&time=' + ts +
'&token=' + token;
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};
}
var data = parseJsonBody(resp.body);
if (data && typeof data === 'object') data.httpStatus = 200;
return data;
},
// 3. Get Song Download URL
// 多源自动排序: 按 this.sourceOrder(当前优先级)依次尝试; 某源解析失败
// (token 失败 / do.php 非 200 / 广告错号 / 无有效 URL)时立即 demote 到队尾,
// 下次点歌自动优先使用更可靠的源。全部源失败返回 null。
getSongUrl: async function(musicno, resolution, h265, _preferredLs) {
resolution = resolution || '720';
h265 = !!h265;
var self = this;
var lastDetail = '';
var lastAttemptHost = '';
// 本次取址命中的源(成功才写入)——由桥在返回地址前用 android.onSongSource 取走,
// 供"只有官方源成功才上报自建服务器"的判定(见 Kotlin SongSourceFlow)。
self.lastSourceHost = '';
for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) {
if (deviceRound > 0) self.regenerateDevice();
// 每一轮使用当时的优先级快照; demote 动态修改 sourceOrder。
var order = self.sourceOrder.slice();
for (var hostIndex = 0; hostIndex < order.length; hostIndex++) {
var host = order[hostIndex];
lastAttemptHost = host;
self._log('Try source (' + (hostIndex + 1) + '/' + order.length + '): ' + host);
var token = await self.getToken(host, false);
if (!token) {
lastDetail = 'token-fail';
self._log('token-fail: ' + host + ' → demote');
self.demoteSource(host);
continue;
}
// 各源同后台, 只试 ls=1(其余 ls 值会被其风控直接拒绝, 反复试会放大请求量)。
var lsValues = ['1'];
var hostOk = false;
var hostDetail = '';
for (var lsi = 0; lsi < lsValues.length && !hostOk; lsi++) {
var ls = lsValues[lsi];
try {
// App 令牌(自建源)在**每次请求前**取一次:原生侧有缓存,这里不会多打请求;
// sentAppToken 用于判定"401 是不是我们带的令牌被拒"(没带令牌的 401 是源站自己的事)。
var appHeaders = appTokenHeader(host);
var sentAppToken = !!appHeaders['X-App-Token'];
var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
// token 恒定不变; do.php 403"检测到异常行为"是风控限流, 与 token 过期无关。
// ⚠️ 2026-10-15 点歌延迟优化:**首次请求永远立即发出**(上面那一行就是首次请求),
// 这里只决定"要不要再试一次":
// * 终态失败(风控 403 / 业务码 404 / HTTP 4xx)→ **不退避、不重试**,直接换下一个源
// (旧实现固定 `sleep(3000)` 再重试一次 —— 那就是点歌后干等 3 秒的直接原因);
// * 可重试失败(广告/错号资源、HTTP 5xx、异常)→ 退避 RETRY_BACKOFF_MS 后重试一次。
var needRetry = data.code !== 200;
// App 令牌被拒(401 + 令牌无效或已过期):清原生缓存 → 重取 → **立即**重试一次,
// 不走退避(那是留给风控/广告资源等真源的)。与 Kotlin 侧 withToken 同一口径。
var tokenRejected = sentAppToken && (
data.httpStatus === 401 ||
(data.body && String(data.body).indexOf('令牌无效或已过期') >= 0));
if (!needRetry && data.code === 200 && isDemoUrl(data.data || '', musicno)) {
self._log('广告/错号资源 (ls=' + ls + '): ' + String(data.data).slice(0, 90));
needRetry = true;
}
if (needRetry) {
hostDetail = 'do.php code=' + data.code + ' http=' + (data.httpStatus || 0);
if (tokenRejected) {
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 after ' + RETRY_BACKOFF_MS + 'ms');
await sleep(RETRY_BACKOFF_MS);
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
}
}
if (data.code === 200) {
var songUrl = data.data || '';
// 任务4: 返回的文件编号与请求不一致即视为广告/错误资源, 一律丢弃。
if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) {
self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host);
self.promoteSource(host);
// 本次命中的源(Android 侧据此决定是否上报自建服务器)。
self.lastSourceHost = host;
hostOk = true;
// 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。
reportSongResult(true, host, musicno, 'ok');
self._timing('点歌→取到地址', host);
return songUrl;
}
if (isDemoUrl(songUrl, musicno)) {
hostDetail = '仍为广告/错号资源 (ls=' + ls + ')';
self._log(hostDetail + ': ' + String(songUrl).slice(0, 90));
}
}
} catch(e) {
hostDetail = e.message;
self._log('Failed ls=' + ls + ': ' + host + ', ' + e.message);
}
}
// 该源所有 ls 都未取到有效 URL → 判定解析失败, 排到最后。
if (!hostOk) {
lastDetail = hostDetail || 'no-url';
self._log('Source failed (' + (hostDetail || 'no-url') + ') → demote: ' + host);
self.demoteSource(host);
}
}
}
self._log('All sources exhausted for ' + musicno + ' (' + lastDetail + ')');
// 任务3: 全部源失败 → 上报最后尝试的源与原因。
reportSongResult(false, lastAttemptHost, musicno, lastDetail || 'all-sources-failed');
return null;
},
// 4. Full init sequence
init: async function() {
// token 获取属于每个主/备节点请求的一部分;这里不提前绑定单一节点。
return true;
}
};
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 16;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
}
})(typeof window !== 'undefined' ? window : this);