接入自建歌曲源服务器:源列表自建优先 + App 令牌 + 媒体地址允许清单 + 推送默认目标(V1.6.1/44,未发布)

源列表(自建源优先、官方源兜底)
- SongUrlResolver.DEFAULT_HOSTS 与 ktv_api.js 的 CONFIG.HOSTS/HOST 改为
  https://mdmuse.kasugano.cn + http://zk.ktv1.top:6677;
- 新增设置项 KtvStore.sourceHosts(一行一个可编辑)与纯 JVM SourceHosts(解析/归一化/
  自建源判定),App 与 JS 都以它为准(JS 走既有配置注入:KtvJsBridge 覆写 window.KtvApiConfig)。

App 令牌(自适应,不破坏官方源/老服务端)
- 新增纯 JVM AppToken/AppTokenSession(取/缓存/404-405 记忆不支持/401 清缓存重试一次)
  与胶水 AppTokenClient(同一条干净裸 Socket、8 秒超时、失败只写 MiliLog.local*);
- /music/do.php 与 /push_download 前先对当前源 GET /token,取到即带 X-App-Token
  (expires_in 提前 30 秒过期);官方源 404/405 记住后不再尝试;401 只重试一次;
- /i.php、/music/do.php 的参数与签名算法一个字未改(令牌只加一个请求头);
- JS 侧同步:android.appToken(host)/invalidateAppToken(host) + 401 立即重试一次。

媒体地址闸门(自建源适配)
- 新增设置项 KtvStore.allowedMediaHosts(默认 md.kasugano.cn + 192.168.100.232);
- AdVideoFilter.isTrustedSongUrl(url, musicno, allowedHosts) 只对命中主机放行"非域名"
  限制(裸 IP);黑名单 wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss 与
  ".ts 文件名必须等于编号"一律不变且优先级更高(命中清单也拒);
- 四处调用点同口径:KtvJsBridge.isTrustedDownloadUrl、SongApiClient.isLikelyDemoUrl、
  JS isDemoUrl、下载兜底。

点歌推送默认目标
- PushDownload.effectiveServerUrl:显式配置优先;留空且首源是自建源则默认用它;
  off/none/关闭 等显式关闭;pushToken 逻辑不变(服务端接受 X-App-Token 或 push_token)。

版本与测试
- versionName 1.6.1 / versionCode 44;KTV_BRIDGE_API 13 -> 14(JS 两处 + KtvJsBridge 两处);
- 设置页「数据设置」新增「歌曲源」「媒体地址允许清单」两项;
- 新增单测 AppTokenTest(11)/SourceHostsTest(7)/MediaHostAllowlistTest(8),共 65 例全过。
This commit is contained in:
zijunchiang
2026-10-14 02:46:23 +08:00
parent a20aa7b1e2
commit e00a1ace79
16 changed files with 1477 additions and 81 deletions
+92 -18
View File
@@ -12,7 +12,7 @@
(function(global) {
'use strict';
// KTV_BRIDGE_API: 13 (Android 缓存校验标记,请勿删除)
// KTV_BRIDGE_API: 14 (Android 缓存校验标记,请勿删除)
// ─── 配置 ──────────────────────────────────────────
const CONFIG = {
@@ -21,13 +21,23 @@
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
VN: '4.100.5.11121504',
VER: '4',
// 歌曲源入口(2026-10-09 起**只保留这一个**)。
// zk.ktv1.top:6677 实测与 e.ac19.cn 同后台(i.php 逐字段一致、do.php 同款 SUCCESS + 真源 CDN),
// 连续两首均返回"文件名=编号"的真源;其余入口只发广告或整体 403,已按用户要求删除。
// 歌曲源入口:**自建源优先、官方源兜底**(2026-10-14)。
// https://mdmuse.kasugano.cn = 自建歌曲源(App 令牌 / 点歌推送的宿主;媒体站 md.kasugano.cn:4433);
// http://zk.ktv1.top:6677 = 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。
// ⚠️ 与 Android 侧 SongUrlResolver.DEFAULT_HOSTS(SourceHosts.DEFAULT)**逐项一致**;
// 运行时由 App 设置项注入覆盖(window.KtvApiConfig.HOSTS / HOST)。
HOSTS: [
'https://mdmuse.kasugano.cn',
'http://zk.ktv1.top:6677',
],
HOST: 'http://zk.ktv1.top:6677',
HOST: 'https://mdmuse.kasugano.cn',
// 媒体地址允许清单(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'
};
@@ -142,6 +152,18 @@
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)
@@ -158,8 +180,9 @@
// 点歌拿到了 gz.ac16.vip 的广告视频,而它通过了下述所有旧规则 → 被当成歌曲下载播放。
if (/(?:^|\.)ac16\.vip$/i.test(u.host)) return true;
if (/\/ad_files\/|my_ad_video/i.test(u.path)) return true;
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(u.host)) return true;
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源
// 裸 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**。
@@ -173,6 +196,28 @@
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 {
@@ -182,10 +227,16 @@
} catch(e) { /* 日志失败不影响点歌 */ }
}
async function httpGet(url, timeout) {
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));
}
@@ -193,6 +244,9 @@
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')); };
@@ -341,9 +395,9 @@
this.token = '';
},
_fetchSongUrl: async function(host, musicno, token, ls, resolution, h265) {
_fetchSongUrl: async function(host, musicno, token, ls, resolution, h265, appHeaders) {
var ts = Math.floor(Date.now() / 1000);
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 保持一致。
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 保持一致(**一个字都不能改**)。
var params = 'appid=' + CONFIG.APP_ID +
'&device=' + this.mac + '_' + this.sn +
'&ish265=' + (h265 ? '1' : '0') +
@@ -354,11 +408,16 @@
'&time=' + ts +
'&token=' + token;
var sign = md5(params + CONFIG.SDK_KEY);
var resp = await httpGet(host + '/music/do.php?' + params + '&sign=' + sign);
// App 令牌:自建源先 GET /token(原生侧缓存),有令牌才多带一个 X-App-Token 头;
// 官方源(404/405)恒为空对象,请求形态与改造前**逐字节一致**。
var resp = await httpGet(host + '/music/do.php?' + params + '&sign=' + sign, null,
appHeaders || appTokenHeader(host));
if (resp.status !== 200) {
return {code: null, data: '', httpStatus: resp.status};
return {code: null, data: '', httpStatus: resp.status, body: resp.body};
}
return parseJsonBody(resp.body);
var data = parseJsonBody(resp.body);
if (data && typeof data === 'object') data.httpStatus = 200;
return data;
},
// 3. Get Song Download URL
@@ -396,20 +455,35 @@
for (var lsi = 0; lsi < lsValues.length && !hostOk; lsi++) {
var ls = lsValues[lsi];
try {
var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
// 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 过期无关。
// 403/失败/广告命中时同 token 短退避重试一次, 不再清 token 重取 i.php。
// 广告/错号资源(如 wb66 719.ts)也算失败, 退避重试一次等真源。
var needRetry = data.code !== 200;
// App 令牌被拒(401 + 令牌无效或已过期):清原生缓存 → 重取 → **立即**重试一次,
// 不走 3 秒退避(那是留给风控/广告资源等真源的)。与 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);
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; retry once with same token');
await sleep(3000);
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
if (tokenRejected) {
invalidateAppToken(host);
appHeaders = appTokenHeader(host);
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; App 令牌被拒 → 重取后只重试一次');
} else {
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; retry once with same token');
await sleep(3000);
}
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265, appHeaders);
}
if (data.code === 200) {
@@ -458,7 +532,7 @@
// ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 13;
global.KtvBridgeApiVersion = 14;
if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};