615 lines
30 KiB
JavaScript
615 lines
30 KiB
JavaScript
/**
|
||
* 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);
|