接入自建歌曲源服务器:源列表自建优先 + 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
+2 -2
View File
@@ -20,8 +20,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 43
versionName "1.6.0"
versionCode 44
versionName "1.6.1"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+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};
@@ -53,6 +53,40 @@ object AdVideoFilter {
@JvmField
var replacementUrl: String? = null
/**
* **媒体地址允许清单**(设置项 `KtvStore.allowedMediaHosts`,默认
* `md.kasugano.cn` + `192.168.100.232`)。
*
* 只放行"**必须是域名**"这一条限制:直连内网自建源时媒体地址就是裸 IP
* (`http://192.168.100.232:6677/media/<编号>.ts`),旧规则会把它当 demo 误杀。
*
* ⚠️ 它**不是**"任意裸 IP 都行":只有清单里的主机才放行,其余规则(广告黑名单
* wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss、`.ts` 文件名必须等于编号)
* 一律不变,且**黑名单优先级最高**(命中清单也要拒 —— 见 [isTrustedSongUrl] 里的判定顺序)。
*/
@Volatile
@JvmField
var allowedMediaHosts: Set<String> = SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.toSet()
/** 装载允许清单(条目会被归一化成纯主机名,见 [SourceHosts.normalizeAllowedHost])。 */
@JvmStatic
fun setAllowedMediaHosts(hosts: Collection<String>?) {
allowedMediaHosts = SourceHosts.parseAllowedList(hosts).toSet()
}
/**
* 该主机是否命中允许清单(精确匹配或子域匹配,均为小写)。
*
* 消费方:[isTrustedSongUrl](放行裸 IP)、`KtvJsBridge.isTrustedDownloadUrl`
* (放行"必须是已知 CDN 后缀"这条域名限制)、`SongApiClient.isLikelyDemoUrl`。
*/
@JvmStatic
fun isAllowedMediaHost(host: String?): Boolean {
val value = host?.trim()?.lowercase().orEmpty()
if (value.isEmpty()) return false
return allowedMediaHosts.any { value == it || value.endsWith(".$it") }
}
/**
* 判断是否为广告视频地址(路径特征 + 域名)。
*
@@ -75,11 +109,13 @@ object AdVideoFilter {
*
* 判定为不可信的情况:
* 1. 非 http(s);
* 2. 命中广告特征([isAdVideo]);
* 2. 命中广告特征([isAdVideo],**优先级最高**:命中允许清单也要拒);
* 3. **URL 路径里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号
* (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。
* 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行,
* 这正是本次广告漏放的直接原因。
* 4. 裸 IP / `pub-*.r2.dev/ss/` 这类"非域名"地址 —— **除非主机命中
* [allowedMediaHosts]**(自建源直连内网时媒体就是裸 IP,见 [setAllowedMediaHosts])。
*
* ⚠️ 第 3 条**必须在剥离 query/fragment 后的 path 上做**:真源地址带 `?token=…&t=…`
* (2026-10-11 抓包:`…/480p/7200088.ts?token=…&t=…`)。旧正则写成
@@ -87,15 +123,27 @@ object AdVideoFilter {
* 规则被跳过(漏放)**;而拿整串找特征又会把 query 里的签名串当广告特征(误杀)。
*/
@JvmStatic
fun isTrustedSongUrl(url: String?, musicno: String?): Boolean {
fun isTrustedSongUrl(url: String?, musicno: String?): Boolean =
isTrustedSongUrl(url, musicno, allowedMediaHosts)
/** [isTrustedSongUrl] 的可注入版本(单测用:不依赖全局设置项)。 */
@JvmStatic
fun isTrustedSongUrl(url: String?, musicno: String?, allowedHosts: Collection<String>?): Boolean {
if (url.isNullOrEmpty()) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
if (isAdVideo(url)) return false
val host = hostOf(url) ?: return false
val path = pathOf(url)
// 允许清单:只放行"非域名"限制(裸 IP)。黑名单已在 isAdVideo 里拦过,优先级更高。
val allowed = allowedHosts?.let { hosts ->
hosts.any { entry ->
val it = entry.trim().lowercase()
it.isNotEmpty() && (host == it || host.endsWith(".$it"))
}
} ?: false
// 测试资源主机(与 JS `isDemoUrl` / 服务端 `resolver.is_trusted_url` 同口径):
// 裸 IP —— App 只接受域名;`pub-*.r2.dev/ss/` —— Cloudflare 公开测试桶。
if (IP_HOST.matches(host)) return false
// 裸 IP —— App 只接受域名(**命中允许清单的除外**);`pub-*.r2.dev/ss/` —— Cloudflare 公开测试桶。
if (IP_HOST.matches(host) && !allowed) return false
if (R2_HOST.matches(host) && path.contains("/ss/")) return false
val name = tsBasename(path)
if (name != null && !musicno.isNullOrEmpty() && name != musicno) return false
@@ -148,7 +196,7 @@ object AdVideoFilter {
/** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对(入参是 path)。 */
private val TS_BASENAME = Regex("/([^/]+)\\.ts(?:/|$)", RegexOption.IGNORE_CASE)
/** 裸 IP 主机(IPv4 字面量):App 只接受域名形式的媒体地址。 */
/** 裸 IP 主机(IPv4 字面量):App 只接受域名形式的媒体地址(命中允许清单的除外)。 */
private val IP_HOST = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")
/** Cloudflare R2 公开测试桶主机(配合 path 里的 `/ss/` 判定,见 `isTrustedSongUrl`)。 */
+211
View File
@@ -0,0 +1,211 @@
package com.local.ktv
/**
* 自建歌曲源的 **App 令牌**(`GET /token`)—— 纯逻辑(不碰 Android API,可跑纯 JVM 单测)。
*
* ## 背景(服务端契约,见 `selfhost-source/server/ktv/apptoken.py`)
* * `GET https://mdmuse.kasugano.cn/token` → `{"ok":true,"token":"<43 字符>","expires_in":600,…}`
* (默认 10 分钟、`Cache-Control: no-store`;同一出口 IP 每分钟最多签发 60 个);
* * 受管路径默认 `/music/do.php,/push_download`,令牌走**请求头 `X-App-Token`**;
* * 服务端 `app_token_enforce=false`(**老 App 不带令牌照常可用**,也绝不能强制)——
* 只有"带了令牌"才校验,带错的会 **401**(body `{"ok":false,"error":"令牌无效或已过期,请重新获取"}`)。
*
* 因此 App 侧的策略必须是**自适应**的:
* 1. 调 `/music/do.php` / `/push_download` 前先对**当前源**试一次 `/token`;
* 2. 拿到就缓存(按 `expires_in`,**提前 30 秒**失效)并在请求里带 `X-App-Token`;
* 3. 源不认这个接口(**404/405**,官方源就是这种)→ **记住"该源不支持令牌"**,
* 之后不再尝试、也不带这个头(否则每首歌白多一次请求、还拖慢取址);
* 4. 带令牌被拒(401 + `令牌无效或已过期`)→ 清缓存、重取一次,**只重试一次**。
*
* ⚠️ `/i.php` 与 `/music/do.php` 的**参数与签名算法一个字都不能改**:这里的令牌只体现在
* 额外的一个请求头 `X-App-Token` 上(服务端口径见 `apptoken.py` 的 `_presented()`)。
*/
object AppToken {
/** 令牌接口路径(拼在**当前源**后面:`<host>/token`)。 */
const val PATH = "/token"
/** 携带令牌的请求头(服务端只认这个头,见 `apptoken.py` 的 `_presented()`)。 */
const val HEADER = "X-App-Token"
/** 服务端没给 `expires_in` 时的兜底有效期(秒)。 */
const val DEFAULT_EXPIRES_IN_SEC = 600L
/** 单次 `/token` 的超时(连接 + 读取),与 [PushDownload.TIMEOUT_MS] 同一量级。 */
const val TIMEOUT_MS = 8_000
/** **提前过期量**:令牌在真正过期前 30 秒就不再使用(避免卡在边界上被拒)。 */
const val EARLY_EXPIRE_MS = 30_000L
/**
* 拿不到令牌后的**短冷却**(非 404/405 的失败:网络异常 / 5xx / 429 / 响应解析不出令牌)。
*
* 目的与"记住不支持"一样:**不能每首歌都多打一次 `/token`**;冷却过后自然重试一次,
* 因而"自建源临时抖一下"不会永久退化成不带令牌。
*/
const val FAILURE_BACKOFF_MS = 60_000L
/** 服务端"令牌无效/过期"的统一文案(401 响应体里就是它)。 */
const val UNAUTHORIZED_MARKER = "令牌无效或已过期"
/** 令牌接口完整地址(`<host>/token`,自动去掉源末尾的 `/`)。 */
@JvmStatic
fun endpoint(host: String): String = host.trim().trimEnd('/') + PATH
/** 该 HTTP 状态是否表示"**这个源根本没有令牌接口**"(官方源)。 */
@JvmStatic
fun isUnsupportedStatus(status: Int): Boolean = status == 404 || status == 405
/**
* 是否"令牌被拒":HTTP 401,**或**响应体里带着服务端的过期文案
* (有的反代会把 401 包装成 200,按文案兜底)。
*/
@JvmStatic
fun isUnauthorized(status: Int, body: String?): Boolean =
status == 401 || (!body.isNullOrEmpty() && body.contains(UNAUTHORIZED_MARKER))
/**
* 从 `/token` 响应体里取 `token`(**手写解析**:单测里 `org.json` 是空壳,见 [SourceHosts] 同类注释)。
*/
@JvmStatic
fun parseToken(body: String?): String? {
val match = Regex("\"token\"\\s*:\\s*\"([^\"]+)\"").find(body ?: return null) ?: return null
return match.groupValues.getOrNull(1)?.takeIf { it.isNotEmpty() }
}
/** 从 `/token` 响应体里取 `expires_in`(秒);没有则返回 null → 用 [DEFAULT_EXPIRES_IN_SEC]。 */
@JvmStatic
fun parseExpiresIn(body: String?): Long? {
val match = Regex("\"expires_in\"\\s*:\\s*\"?(\\d+)").find(body ?: return null) ?: return null
return match.groupValues.getOrNull(1)?.toLongOrNull()
}
}
/** 一次 HTTP 响应(**纯数据**:不依赖 Android,也不依赖单测里是空壳的 `org.json`)。 */
class TokenHttp @JvmOverloads constructor(
@JvmField val status: Int,
@JvmField val body: String,
@JvmField val error: String? = null,
)
/**
* 令牌会话:**取 / 缓存 / "该源不支持令牌"记忆 / 401 清缓存重试一次**(纯 JVM,可单测)。
*
* 线程安全(`synchronized`):取址在 IO 线程、JS 桥在自己的 JavaBridge 线程、点歌推送在独立
* 守护线程,三处可能同时问同一个 host 的令牌。
*
* @param get 同步 GET,入参 url,返回 [TokenHttp](生产 = 干净裸 Socket 请求,见
* [AppTokenClient];单测 = 假实现)。
* @param clock 时钟(毫秒)。注入是为了让"过期重取"不必真的等 10 分钟。
*/
class AppTokenSession(
private val get: (String) -> TokenHttp,
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>()
/** 已确认"不支持令牌"的源(404/405):**永久记住**,之后连请求都不发。 */
private val unsupported = HashSet<String>()
/** 非 404/405 的失败 → 冷却到该时刻(避免每首歌都多打一次 /token)。 */
private val retryAt = HashMap<String, Long>()
/** 单测用:每个 host 实际请求过几次 `/token`。 */
private val fetches = HashMap<String, Int>()
/** 单参构造:用系统时钟(生产路径)。 */
constructor(get: (String) -> TokenHttp) : this(get, { System.currentTimeMillis() })
/**
* 取当前源的 App 令牌。
*
* 返回 **null = 这次不要带 `X-App-Token`**(源不支持 / 暂时取不到 / 已有缓存但过期且取新失败);
* 请求照常发出(服务端 `app_token_enforce=false` 时老请求形态依旧可用)。
*/
fun tokenFor(host: String): String? = synchronized(lock) {
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 // 未过期 → 直接复用
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** 时调用)。 */
fun invalidate(host: String) {
synchronized(lock) {
val key = key(host) ?: return
tokens.remove(key)
retryAt.remove(key)
// 令牌被拒说明该源**是支持令牌的**:把"不支持"记忆一起清掉(安全网,正常不会命中)。
unsupported.remove(key)
}
}
/** 该源是否已被记住"不支持令牌"(404/405)。 */
fun isUnsupported(host: String): Boolean = synchronized(lock) {
unsupported.contains(key(host).orEmpty())
}
/** 该源实际请求过几次 `/token`(单测用:核对"记住不支持后不再尝试")。 */
fun fetchCount(host: String): Int = synchronized(lock) { fetches[key(host).orEmpty()] ?: 0 }
/** 当前缓存的令牌是否还没过期(单测用)。 */
fun hasFreshToken(host: String): Boolean = synchronized(lock) {
val entry = tokens[key(host).orEmpty()] ?: return false
clock() < entry.expiresAtMs
}
/**
* 带令牌执行一次请求,并在**带令牌被拒**时"清缓存 → 重取 → **只重试一次**"。
*
* @param request 真正的请求动作:入参是本次要带的令牌(可能为 null)。
* @param unauthorized 判定"令牌被拒":入参是响应与**本次实际带的令牌**
* (约定:只有真的带了令牌,401 才算"令牌被拒";没带令牌的 401 是源站自己的事)。
*/
fun <T> withToken(host: String, request: (String?) -> T, unauthorized: (T, String?) -> Boolean): T {
val firstToken = tokenFor(host)
val first = request(firstToken)
if (!unauthorized(first, firstToken)) return first
invalidate(host)
return request(tokenFor(host)) // 重取一次;只重试一次,第二次无论结果如何都直接返回
}
/** [withToken] 的默认判定版(本项目的实际口径):401 + 确实带了令牌。 */
fun withTokenHttp(host: String, request: (String?) -> TokenHttp): TokenHttp =
withToken(host, request) { resp, token ->
token != null && AppToken.isUnauthorized(resp.status, resp.body)
}
private fun key(host: String?): String? =
host?.trim()?.trimEnd('/')?.takeIf { it.isNotEmpty() }
}
@@ -0,0 +1,84 @@
package com.local.ktv
import org.json.JSONObject
/**
* App 令牌的 **Android 侧胶水**:把 [AppTokenSession](纯逻辑)接到项目的"干净请求"通道上。
*
* ## 与 [PushDownloadClient] 同一套路
* * **只在后台线程调用**(取址的 IO 线程 / JS 桥的 JavaBridge 线程 / 点歌推送的守护线程);
* 任何一步都不允许在 UI 线程发起([AppToken.TIMEOUT_MS] 秒级阻塞);
* * 单次请求 8 秒超时(通道自带),**失败只写 `MiliLog.local*`**;
* * 拿不到令牌就"不带这个头"继续发请求 —— 服务端 `app_token_enforce=false` 时老形态照常可用,
* 因此令牌链路**绝不会**影响取址、下载与播放。
*
* ## 通道
* 走 [KtvJsBridge.rawCleanGet](只发 Host + Accept 的裸 Socket):与 JS、原生取址同一条路,
* 官方源不认 `/token` 时会回 **404/405** → 被 [AppTokenSession] 记住,之后再不多发请求。
*/
object AppTokenClient {
/** 干净 GET 通道(入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串)。 */
@Volatile
private var channel: ((String) -> String?)? = null
private val session = AppTokenSession({ url -> envelope(url) })
/** 安装通道(应传 [KtvJsBridge.rawCleanGet];见 `SongApiClient.init`)。 */
@JvmStatic
fun install(get: (String) -> String?) {
channel = get
}
/**
* 取当前源的 App 令牌;**null = 本次不要带 `X-App-Token`**。
*
* ⚠️ **只能在后台线程调用。**
*/
@JvmStatic
fun tokenFor(host: String): String? =
runCatching { session.tokenFor(host) }.onFailure {
MiliLog.localWarn("APP-TOKEN 取令牌异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
}.getOrNull()
/** 清掉某源的令牌缓存(401 时由 [AppTokenSession] 内部调用;此入口供 JS 侧显式失效)。 */
@JvmStatic
fun invalidate(host: String) {
runCatching { session.invalidate(host) }
}
/**
* 带令牌执行一次请求(**自建源的 `/music/do.php` 与 `/push_download` 都走它**):
* 先取令牌,带令牌被拒(401 + `令牌无效或已过期`)时清缓存、重取一次、**只重试一次**。
*
* ⚠️ **只能在后台线程调用。**
*/
@JvmStatic
fun withToken(host: String, request: (String?) -> TokenHttp): TokenHttp =
runCatching { session.withTokenHttp(host, request) }.getOrElse {
MiliLog.localWarn("APP-TOKEN 请求异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
// 令牌链路出任何意外都退化成"不带令牌的一次普通请求",绝不让取址/推送失败在令牌上。
request(null)
}
/** 该源是否已被记住"不支持令牌"(供日志/自检)。 */
@JvmStatic
fun isUnsupported(host: String): Boolean =
runCatching { session.isUnsupported(host) }.getOrDefault(false)
/** 解 `{status,body,error}` 信封;404/405 记一条"该源不支持令牌"(只写本地日志)。 */
private fun envelope(url: String): TokenHttp {
val getter = channel ?: return TokenHttp(0, "", "no-channel")
val raw = getter(url) ?: return TokenHttp(0, "", "no-response")
val env = runCatching { JSONObject(raw) }.getOrNull() ?: return TokenHttp(0, "", "bad-envelope")
val status = env.optInt("status")
val body = env.optString("body")
val error = env.optString("error")
if (AppToken.isUnsupportedStatus(status)) {
MiliLog.local("APP-TOKEN 源不支持令牌接口(HTTP $status),之后不再尝试 source=$url")
} else if (status !in 200..299) {
MiliLog.localWarn("APP-TOKEN 取令牌失败 HTTP $status ${error.take(80)}")
}
return TokenHttp(status, body, error)
}
}
+73 -13
View File
@@ -11,6 +11,7 @@ import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebView
import android.webkit.WebViewClient
import org.json.JSONArray
import org.json.JSONObject
import java.io.BufferedInputStream
import java.io.ByteArrayOutputStream
@@ -211,10 +212,14 @@ class KtvJsBridge(context: Context) {
* 与 JS 的 `android.httpGet` 走的是**完全同一条**裸 Socket 路径(只发 Host + Accept,
* 不带 UA/Connection/Accept-Encoding),因此请求特征与风控表现一致;
* 且**不依赖 WebView 是否就绪** —— 正是 WebView 不可用的老设备所需要的。
*
* @param appToken 自建源的 App 令牌([AppToken.HEADER],见 [AppTokenClient]);
* 为空时**一个字节都不多加**,官方源/老服务端的请求形态完全不变。
*/
fun rawCleanGet(url: String, timeoutMs: Int = 8_000): String {
val headers = JSONObject().put("Accept", "*/*").toString()
return NativeBridge().request("GET", url, null, headers, timeoutMs)
fun rawCleanGet(url: String, appToken: String? = null, timeoutMs: Int = 8_000): String {
val headers = JSONObject().put("Accept", "*/*")
if (!appToken.isNullOrEmpty()) headers.put(AppToken.HEADER, appToken)
return NativeBridge().request("GET", url, null, headers.toString(), timeoutMs)
}
/**
@@ -226,13 +231,13 @@ class KtvJsBridge(context: Context) {
* `action=get_config&device_id=<mac>`;而 `i.php`/`do.php` 仍是**只有 Host+Accept**
* 的干净 GET([rawCleanGet])。这里按同样分工实现。
*/
fun rawCleanPost(url: String, form: String, timeoutMs: Int = 8_000): String {
fun rawCleanPost(url: String, form: String, appToken: String? = null, timeoutMs: Int = 8_000): String {
val headers = JSONObject()
.put("Accept", "*/*")
.put("Content-Type", "application/x-www-form-urlencoded")
.put("User-Agent", dalvikUserAgent())
.toString()
return NativeBridge().request("POST", url, form, headers, timeoutMs)
if (!appToken.isNullOrEmpty()) headers.put(AppToken.HEADER, appToken)
return NativeBridge().request("POST", url, form, headers.toString(), timeoutMs)
}
/** 原版客户端形态的 UA(Dalvik + 本机 Android 版本/型号/Build)。 */
@@ -242,6 +247,25 @@ class KtvJsBridge(context: Context) {
fun isDestroyed(): Boolean = destroyed
/**
* 设置项里的歌曲源列表(注入 JS 的 `CONFIG.HOSTS` / `CONFIG.HOST`):
* 读一次 `state.json`(小文件,调用在取址的后台线程上),**空则回落 [SourceHosts.DEFAULT]**。
*
* 与原生兜底 [SongUrlResolver.applySourceHosts] 读的是**同一份设置**,两条取址路径的源一致。
*/
private fun sourceHostsForJs(): List<String> = runCatching {
val store = KtvStore()
store.load()
store.sourceHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT
}.getOrDefault(SourceHosts.DEFAULT)
/** 设置项里的媒体地址允许清单(注入 JS 的 `CONFIG.ALLOWED_MEDIA_HOSTS`)。 */
private fun allowedMediaHostsForJs(): List<String> = runCatching {
val store = KtvStore()
store.load()
store.allowedMediaHosts.takeIf { it.isNotEmpty() } ?: SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS
}.getOrDefault(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS)
/**
* 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。
*
@@ -271,6 +295,10 @@ class KtvJsBridge(context: Context) {
}
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)};
@@ -289,6 +317,10 @@ class KtvJsBridge(context: Context) {
if (window.KtvApiConfig) {
window.KtvApiConfig.VER = '2.0';
window.KtvApiConfig.VN = '4.1.3.03281430';
// 源列表(App 设置项,一行一个;空则回落默认) + 媒体地址允许清单(自建源裸 IP)。
window.KtvApiConfig.HOSTS = ${JSONArray(hosts).toString()};
window.KtvApiConfig.HOST = ${JSONObject.quote(hosts.firstOrNull().orEmpty())};
window.KtvApiConfig.ALLOWED_MEDIA_HOSTS = ${JSONArray(allowedHosts).toString()};
}
window.api = new window.KtvApi({mac: ${JSONObject.quote(DEVICE_MAC)}, sn: ${JSONObject.quote(DEVICE_SN)}, maxDeviceRetries: 0, debug: true});
var _regen = window.api.regenerateDevice;
@@ -329,15 +361,24 @@ class KtvJsBridge(context: Context) {
"ktvdaren.com", "j-make.cn",
)
// 已知 demo 域名模式 (Cloudflare R2 测试桶、IP 地址等)
private val demoHostPatterns = listOf(
Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"), // 纯 IP
Regex("^pub-[a-z0-9]+\\.r2\\.dev$"), // R2 demo bucket
)
private val ipHostPattern = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")
private val r2HostPattern = Regex("^pub-[a-z0-9]+\\.r2\\.dev$")
private val demoHostPatterns = listOf(ipHostPattern, r2HostPattern)
private fun isTrustedDownloadUrl(url: String?): Boolean {
if (url == null) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return false
val host = AdVideoFilter.hostOf(url) ?: return false
// R2 公开测试桶(`pub-*.r2.dev/ss/`)是**黑名单**,优先级最高:命中允许清单也要拒
// (与 AdVideoFilter.isTrustedSongUrl 的判定顺序保持一致)。
if (r2HostPattern.matches(host) && AdVideoFilter.pathOf(url).contains("/ss/")) {
Log.w(TAG, "Rejected demo URL host: $host")
return false
}
// 媒体地址允许清单(自建源:md.kasugano.cn / 内网裸 IP):放行"必须是已知 CDN 域名"
// 与"必须是域名"这两条限制。其余广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video)
// 已由调用点的 AdVideoFilter.resolve()(isAdVideo)先拦过一遍,**优先级更高**。
if (AdVideoFilter.isAllowedMediaHost(host)) return true
if (demoHostPatterns.any { it.matches(host) }) {
Log.w(TAG, "Rejected demo URL host: $host")
return false
@@ -359,7 +400,7 @@ class KtvJsBridge(context: Context) {
runCatching {
file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let {
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 13")
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 14")
}
}.getOrDefault(false)
@@ -385,7 +426,7 @@ class KtvJsBridge(context: Context) {
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 13"),
script.contains("KTV_BRIDGE_API: 14"),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
@@ -480,6 +521,25 @@ class KtvJsBridge(context: Context) {
fun httpPost(url: String, body: String, headersJson: String, timeoutMs: Int): String =
request("POST", url, body, headersJson, timeoutMs)
/**
* JS 侧取**当前源**的 App 令牌(自建源 `GET /token`,见 [AppTokenClient])。
*
* 返回空串 = 本次不要带 `X-App-Token`(官方源不支持 / 暂时取不到 / 已被拒)——
* 令牌逻辑与缓存全在 Kotlin 侧一处([AppTokenSession]),JS 只负责把这个头带上。
*
* ⚠️ 本方法在 WebView 的 **JavaBridge 线程**(非 UI 线程)被调用,与
* `httpGet` 一样允许阻塞;失败一律降级成空串,绝不影响取址。
*/
@JavascriptInterface
fun appToken(host: String): String =
runCatching { AppTokenClient.tokenFor(host) }.getOrNull().orEmpty()
/** JS 侧令牌被拒(401)时显式清缓存,配合"重取一次、只重试一次"。 */
@JavascriptInterface
fun invalidateAppToken(host: String) {
runCatching { AppTokenClient.invalidate(host) }
}
// 任务3: JS 点歌结果上报(成功/失败 + 实际命中的歌曲源 host)。
// 由 Kotlin 调用方(SongOkDownloadManager)补充标题后写入远程日志,
// 同时成功即表示窗口/网络恢复 → 复位 e.ac19 窗口守卫提示状态。
+58 -1
View File
@@ -68,11 +68,33 @@ class KtvStore {
* 「App 点歌推送」:把点歌取到的下载地址同步推给自建服务器(`POST <地址>/push_download`,
* 见 [PushDownload] / [PushDownloadClient] / [SongApiClient.getSongDownloadUrl])。
*
* 默认**空 = 关闭该功能**:为空时连请求都不发(现有用户完全无感)。
* * **留空 = 自动**:若 [sourceHosts] 的第一项是自建源,就用它当推送目标
* (见 [PushDownload.effectiveServerUrl],2026-10-14 自建源接入后成为默认行为);
* * **填 `off` / `none` / `关闭` = 显式关闭**(连请求都不发);
* * 填其它 `http(s)://…` = 显式覆盖(现有用户完全不受影响)。
*/
@JvmField var pushServerUrl = ""
/** 推送鉴权串:必须与服务端的 `push_token` 一致(默认 [DEFAULT_PUSH_TOKEN])。 */
@JvmField var pushToken = DEFAULT_PUSH_TOKEN
/**
* **歌曲源入口列表**(自建源优先、官方源兜底,默认 = [SourceHosts.DEFAULT])。
*
* App 侧([SongUrlResolver])与 JS 侧(`KtvJsBridge` 注入 `CONFIG.HOSTS`)**都以它为准**;
* 设置页按"一行一个"编辑,解析/归一化见 [SourceHosts.parse] / [SourceHosts.parseList]
* —— 清空即回落默认列表,绝不会变成"没有源可用"。
*/
@JvmField val sourceHosts: MutableList<String> = SourceHosts.DEFAULT.toMutableList()
/**
* **媒体地址允许清单**(默认 = [SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS]:
* `md.kasugano.cn` + `192.168.100.232`)。
*
* 只放行"媒体地址必须是域名"这一条限制(直连内网自建源时是裸 IP);
* 广告黑名单与 `.ts` 文件名比对优先级更高,见 [AdVideoFilter.setAllowedMediaHosts]。
*/
@JvmField val allowedMediaHosts: MutableList<String> =
SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.toMutableList()
@JvmField val orderQueue = mutableListOf<Song>()
@JvmField val sangHistory = mutableListOf<Song>()
@JvmField val scoreHistory = mutableListOf<String>()
@@ -130,6 +152,11 @@ class KtvStore {
deviceIdentityJson = root.optString("deviceIdentityJson", "")
pushServerUrl = root.optString("pushServerUrl", "")
pushToken = root.optString("pushToken", DEFAULT_PUSH_TOKEN).ifBlank { DEFAULT_PUSH_TOKEN }
// 源列表 / 媒体地址允许清单:空(旧配置没有这两个键)→ 回落默认值。
sourceHosts.clear()
sourceHosts.addAll(SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts"))))
allowedMediaHosts.clear()
allowedMediaHosts.addAll(SourceHosts.parseAllowedList(readStringList(root.optJSONArray("allowedMediaHosts"))))
readSongs(root.optJSONArray("orderQueue"), orderQueue)
readSongs(root.optJSONArray("sangHistory"), sangHistory)
root.optJSONArray("scoreHistory").copyStringsTo(scoreHistory)
@@ -206,6 +233,8 @@ class KtvStore {
put("deviceIdentityJson", deviceIdentityJson)
put("pushServerUrl", pushServerUrl)
put("pushToken", pushToken)
put("sourceHosts", JSONArray(sourceHosts))
put("allowedMediaHosts", JSONArray(allowedMediaHosts))
put("orderQueue", writeSongs(orderQueue))
put("sangHistory", writeSongs(sangHistory))
put("scoreHistory", JSONArray(scoreHistory.take(100)))
@@ -227,6 +256,26 @@ class KtvStore {
}
}
// ── 歌曲源 / 媒体地址允许清单:设置页按"一行一个"编辑,这里只做文本 ↔ 列表的转换 ──
/** 设置页用:源列表的文本形态(一行一个)。 */
fun sourceHostsText(): String = sourceHosts.joinToString("\n")
/** 设置页保存:整段文本 → 归一化后的源列表(清空即回落默认,见 [SourceHosts.parse])。 */
fun applySourceHostsText(text: String) {
sourceHosts.clear()
sourceHosts.addAll(SourceHosts.parse(text))
}
/** 设置页用:媒体地址允许清单的文本形态(一行一个)。 */
fun allowedMediaHostsText(): String = allowedMediaHosts.joinToString("\n")
/** 设置页保存:整段文本 → 归一化后的允许清单(清空即回落默认)。 */
fun applyAllowedMediaHostsText(text: String) {
allowedMediaHosts.clear()
allowedMediaHosts.addAll(SourceHosts.parseAllowed(text))
}
fun toggleFavorite(song: Song): Boolean = toggleId(favoriteIds, stableId(song))
fun isFavorite(song: Song): Boolean = stableId(song) in favoriteIds
@@ -270,6 +319,14 @@ class KtvStore {
repeat(array.length()) { out += Song.fromJson(array.getJSONObject(it)) }
}
/** 读一个字符串数组(`sourceHosts` / `allowedMediaHosts`):缺键 → 空列表(调用方回落默认)。 */
private fun readStringList(array: JSONArray?): List<String> {
if (array == null) return emptyList()
val out = ArrayList<String>(array.length())
repeat(array.length()) { out += array.optString(it) }
return out
}
private fun writeSongs(songs: List<Song>): JSONArray = JSONArray().apply {
songs.take(200).forEach { put(it.toJson()) }
}
+111 -10
View File
@@ -482,6 +482,10 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}")
}
store.load()
// 歌曲源列表 / 媒体地址允许清单(自建源优先、官方源兜底):一处装载,
// 原生取址(SongUrlResolver)、地址闸门(AdVideoFilter)与 JS 取址(KtvJsBridge 注入
// CONFIG.HOSTS / CONFIG.ALLOWED_MEDIA_HOSTS)三处都读它。
applySourceSettings()
// 设备身份(**伪装成一台安卓盒子**,见 DeviceIdentity):必须在任何上报之前装载好
// (onCreate 稍后就会发第一次 whoami 心跳,JS 取址也用它注入 mac/sn)。
// 纯本地小文件读写,不联网;生成一次后固定不变(变了会被上游当成新设备)。
@@ -8863,13 +8867,24 @@ class MainActivity : AppCompatActivity() {
SettingsEntry(
R.drawable.ott_ic_data_setting,
"推送下载",
if (store.pushServerUrl.isBlank()) {
"未配置(默认关闭):点歌时不向自建服务器推送下载地址"
} else {
"已开启:点歌地址推送至 ${store.pushServerUrl}"
},
PushDownload.endpoint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts))
?.let { "已开启:点歌地址推送至 $it" }
?: "未开启(留空 = 自动用首个自建源;填 off 关闭)",
"设置",
) { showPushDownloadSettings() },
SettingsEntry(
R.drawable.ott_ic_wifi_setting,
"歌曲源",
"当前 ${store.sourceHosts.size} 个,优先 " +
store.sourceHosts.firstOrNull().orEmpty(),
"设置",
) { showSourceHostsDialog() },
SettingsEntry(
R.drawable.ott_ic_data_setting,
"媒体地址允许清单",
"放行这些主机的裸 IP 媒体地址(当前 ${store.allowedMediaHosts.size} 个)",
"设置",
) { showAllowedMediaHostsDialog() },
)
2 -> listOf(
SettingsEntry(R.drawable.ott_ic_download_setting, "开机清空下载列表", "开机时删除本地已下载歌曲并清理数据库状态", action = { clearDownloadsOnBoot = !clearDownloadsOnBoot; saveState() }, checked = clearDownloadsOnBoot),
@@ -9356,7 +9371,8 @@ class MainActivity : AppCompatActivity() {
/**
* 「推送下载」设置:把点歌取到的下载地址同步推给自建服务器(服务端 `POST /push_download`)。
*
* ⚠️ **地址留空 = 关闭该功能**(默认):点歌时连请求都不发(见 [PushDownloadClient])。
* ⚠️ **留空 = 自动**:首个源是自建源时直接用它当推送目标(见 [PushDownload.effectiveServerUrl]);
* 填 `off` / `none` / `关闭` 则关闭该功能(点歌时连请求都不发);填其它地址 = 显式覆盖。
* 改完立刻生效(推送时现读 [store]),不需要重启。
*/
private fun showPushDownloadSettings() {
@@ -9366,25 +9382,110 @@ class MainActivity : AppCompatActivity() {
val urlInput = EditText(this)
urlInput.setSingleLine(true)
urlInput.setText(store.pushServerUrl)
urlInput.setHint("如 http://192.168.1.10:6677(留空 = 关闭)")
urlInput.setHint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
.ifEmpty { SourceHosts.DEFAULT.first() } + "(留空 = 自动)")
val tokenInput = EditText(this)
tokenInput.setSingleLine(true)
tokenInput.setText(store.pushToken)
tokenInput.setHint(KtvStore.DEFAULT_PUSH_TOKEN)
box.addView(label("自建服务器地址(留空 = 关闭,点歌时不发任何请求)", 14, Color.WHITE))
box.addView(label("自建服务器地址(留空 = 自动用首个自建源;填 off 关闭)", 14, Color.WHITE))
box.addView(urlInput, LinearLayout.LayoutParams(-1, dp(58)))
box.addView(label("鉴权串(需与服务端 push_token 一致,默认 local)", 14, Color.WHITE))
box.addView(tokenInput, LinearLayout.LayoutParams(-1, dp(58)))
AlertDialog.Builder(this)
.setTitle("推送下载(App 点歌 → 自建服务器)")
.setView(box)
.setMessage("点歌拿到下载地址后,后台异步推送一份给自建服务器(8 秒超时、失败只写本地日志,不影响播放)。服务器会先校验地址、再按编号去重。")
.setMessage("点歌拿到下载地址后,后台异步推送一份给自建服务器(8 秒超时、失败只写本地日志,不影响播放)。服务器会先校验地址、再按编号去重。自建目标会先取 App 令牌并带 X-App-Token(取不到则按原样推,服务端也认 push_token)。")
.setNegativeButton("取消", null)
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
store.pushServerUrl = urlInput.text.toString().trim()
store.pushToken = tokenInput.text.toString().trim().ifEmpty { KtvStore.DEFAULT_PUSH_TOKEN }
saveState()
toast(if (store.pushServerUrl.isEmpty()) "已关闭推送下载" else "已开启推送下载")
val target = PushDownload.endpoint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts))
toast(if (target == null) "已关闭推送下载" else "已开启推送下载")
showSettingsSection(1, false)
}.create().showForTv()
}
/**
* 把「歌曲源 / 媒体地址允许清单」设置应用到两份取址实现与地址闸门。
*
* 一处装载、三处生效:原生取址([SongUrlResolver.applySourceHosts])、地址闸门
* ([AdVideoFilter.setAllowedMediaHosts])、JS 取址([KtvJsBridge] 每次取址读同一份设置注入
* `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`)。启动与设置页保存后各调一次即可。
*/
private fun applySourceSettings() {
SongUrlResolver.applySourceHosts(store.sourceHosts)
AdVideoFilter.setAllowedMediaHosts(store.allowedMediaHosts)
}
/**
* 「歌曲源」设置:**自建源优先、官方源兜底**,一行一个。
*
* 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`)。
*/
private fun showSourceHostsDialog() {
val input = EditText(this).apply {
setSingleLine(false)
minLines = 3
gravity = Gravity.TOP or Gravity.START
setText(store.sourceHostsText())
setHint(SourceHosts.DEFAULT.joinToString("\n"))
}
val box = LinearLayout(this)
box.orientation = LinearLayout.VERTICAL
box.setPadding(dp(32), dp(8), dp(32), dp(16))
box.addView(label("一行一个,按顺序尝试(解析失败的源会自动沉到队尾,不改这里)", 14, Color.WHITE))
box.addView(input, LinearLayout.LayoutParams(-1, dp(160)))
box.addView(
label("只认 http(s):// 开头;留空 = 回落默认:\n" + SourceHosts.DEFAULT.joinToString("\n"), 13, Color.LTGRAY),
)
AlertDialog.Builder(this)
.setTitle("歌曲源(自建源优先、官方源兜底)")
.setView(box)
.setMessage("第一个源同时是「推送下载」的默认目标。清空即恢复默认列表。")
.setNegativeButton("取消", null)
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
store.applySourceHostsText(input.text.toString())
saveState()
applySourceSettings()
toast("歌曲源已更新(${store.sourceHosts.size} 个)")
showSettingsSection(1, false)
}.create().showForTv()
}
/**
* 「媒体地址允许清单」设置:**只放行这些主机的"非域名"限制**(直连内网自建源时媒体是裸 IP)。
*
* 广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss)与
* 「`.ts` 文件名必须等于编号」**优先级更高**,命中清单也要拒(见 [AdVideoFilter.isTrustedSongUrl])。
*/
private fun showAllowedMediaHostsDialog() {
val input = EditText(this).apply {
setSingleLine(false)
minLines = 3
gravity = Gravity.TOP or Gravity.START
setText(store.allowedMediaHostsText())
setHint(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.joinToString("\n"))
}
val box = LinearLayout(this)
box.orientation = LinearLayout.VERTICAL
box.setPadding(dp(32), dp(8), dp(32), dp(16))
box.addView(label("一行一个主机名(可整条粘贴,会自动去掉协议/端口/路径)", 14, Color.WHITE))
box.addView(input, LinearLayout.LayoutParams(-1, dp(160)))
box.addView(
label("留空 = 回落默认:\n" + SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.joinToString("\n"), 13, Color.LTGRAY),
)
AlertDialog.Builder(this)
.setTitle("媒体地址允许清单")
.setView(box)
.setMessage("只用于放行这些主机的裸 IP 媒体地址(如 http://192.168.100.232:6677/media/<编号>.ts)。广告黑名单与文件名比对不受影响,命中清单也一样拒。")
.setNegativeButton("取消", null)
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
store.applyAllowedMediaHostsText(input.text.toString())
saveState()
applySourceSettings()
toast("媒体允许清单已更新(${store.allowedMediaHosts.size} 个)")
showSettingsSection(1, false)
}.create().showForTv()
}
@@ -16,7 +16,9 @@ import java.util.LinkedHashMap
* 端点拼接 / body 拼接 / 5 分钟去重 / "配置为空就绝不调用发送器" —— 全是纯函数;
* 真正发请求(后台线程 + 8 秒超时 + HttpURLConnection)在 [PushDownloadClient]。
*
* ⚠️ 默认**关闭**:`pushServerUrl` 为空时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。
* ⚠️ **默认目标**(2026-10-14 自建源接入后):`pushServerUrl` **留空 = 自动** —— 首源是自建源时
* 就用它当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。
* 关闭时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。
*/
object PushDownload {
/** 与服务端 `push_token` 的默认值保持一致。 */
@@ -31,9 +33,37 @@ object PushDownload {
/** 去重表最多记多少个编号(长跑不涨内存;超了先丢掉过期的)。 */
private const val MAX_REMEMBERED = 512
/**
* 显式"关闭推送"的取值(见 [effectiveServerUrl])。
*
* 为什么需要它:`pushServerUrl` **留空 = 自动**(首源是自建源时就用它当推送目标),
* 所以"清空关闭"必须有一个明确写法,否则用户没法把自动默认关掉。
*/
private val DISABLED_VALUES = setOf("off", "none", "no", "0", "false", "关闭", "禁用")
private val lock = Any()
private val recent = LinkedHashMap<String, Long>()
/**
* 推送目标的**默认规则**(纯函数,可单测):
*
* 1. 显式配置优先:非空且不是 [DISABLED_VALUES] → 原样使用(**现有用户完全不受影响**);
* 2. 显式关闭(`off`/`none`/`关闭`…)→ 返回空串 = 关闭;
* 3. 留空 → 若 `sourceHosts` 的**第一项是自建源**([SourceHosts.isSelfHost])就用它,
* 否则仍关闭(官方源不能当推送目标)。
*
* @return 空串 = 关闭;调用方再交给 [endpoint] 判合法性(非 http(s) 一律按关闭处理)。
*/
@JvmStatic
fun effectiveServerUrl(configured: String?, sourceHosts: List<String>?): String {
val raw = configured?.trim().orEmpty()
if (raw.isNotEmpty()) {
return if (raw.lowercase() in DISABLED_VALUES) "" else raw
}
val first = sourceHosts?.firstOrNull()?.trim().orEmpty()
return if (SourceHosts.isSelfHost(first)) first else ""
}
/**
* 端点拼接:`<配置去掉尾部 />/push_download`。
*
@@ -9,10 +9,13 @@ import java.net.URL
* ## 切入点
* 由 [SongApiClient.getSongDownloadUrl] 在**拿到下载地址之后**调用(那是 JS / 原生 / DB 回退
* 四条取址路径唯一的汇合点)。本类的每一步都**不能影响点歌**:
* * 默认关闭:`pushServerUrl` 为空 → [PushDownload.endpoint] 为 null → **连线程都不起、绝不发请求**
* (现有用户完全无感);
* * 目标默认:`pushServerUrl` 留空时用**首个自建源**(见 [PushDownload.effectiveServerUrl]);
* 显式 `off`/`none`/`关闭` 或首源不是自建源时 [PushDownload.endpoint] 为 null
* → **连线程都不起、绝不发请求**;
* * 5 分钟内存去重([PushDownload.shouldSkip]):同一首歌反复点/重复取址只推一次;
* * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示)。
* * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示);
* * 自建目标额外带 `X-App-Token`([AppTokenClient],服务端"带了就校验"):令牌取不到就照常
* 按老形态推(服务端也接受 body 里的 `push_token`),**令牌链路失败绝不影响推送本身**。
*
* ⚠️ 每次推送会 `KtvStore().load()` 读一次设置(在后台线程里),保证"设置页改完立刻生效",
* 不用为了一个可选的旁路功能把 store 实例透传到取址层。
@@ -40,22 +43,36 @@ object PushDownloadClient {
thread.start()
}
/** 后台线程里的实际动作:读设置 → 判空 → 提交(失败只写本地日志)。 */
/** 后台线程里的实际动作:读设置 → 解析默认目标 → 判空 → 提交(失败只写本地日志)。 */
private fun pushNow(tid: Long, url: String, name: String) {
val store = KtvStore()
store.load()
val serverUrl = store.pushServerUrl
if (PushDownload.endpoint(serverUrl) == null) return // 默认关闭:不发请求(连 POST 都不构造)
// 目标:显式配置优先;留空且首源是自建源 → 默认用它(2026-10-14 起的行为)。
val serverUrl = PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
if (PushDownload.endpoint(serverUrl) == null) return // 关闭:不发请求(连 POST 都不构造)
val host = SourceHosts.hostOf(serverUrl)
val ok = PushDownload.submit(tid, url, name, serverUrl, store.pushToken,
System.currentTimeMillis()) { endpoint, form -> post(endpoint, form) }
System.currentTimeMillis()) { endpoint, form ->
// App 令牌:自建目标先 GET /token(原生侧缓存)并带上 X-App-Token;
// 带令牌被拒(401)→ 清缓存重取一次、**只重试一次**(见 [AppTokenSession.withToken])。
val code = if (host.isNullOrEmpty()) {
post(endpoint, form, null).status
} else {
AppTokenClient.withToken(host) { appToken -> post(endpoint, form, appToken) }.status
}
code in 200..299
}
if (ok) {
// 成功也只写**本地**日志(远程日志只留结论,见 AGENTS.md 的日志五条约定)
MiliLog.local("PUSH-DL 已推送 编号=$tid 服务器=${PushDownload.endpoint(serverUrl)}")
}
}
/** 同步 POST(**只能在后台线程调用**):8 秒超时,返回 true = 服务端 2xx。 */
private fun post(endpoint: String, form: String): Boolean {
/**
* 同步 POST(**只能在后台线程调用**):8 秒超时,返回 `(HTTP 状态, 响应体)`;
* 状态 0 = 连接层失败(异常信息进 [TokenHttp.error],供日志)。
*/
private fun post(endpoint: String, form: String, appToken: String?): TokenHttp {
var conn: HttpURLConnection? = null
return try {
conn = URL(endpoint).openConnection() as HttpURLConnection
@@ -65,6 +82,8 @@ object PushDownloadClient {
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
conn.setRequestProperty("User-Agent", "MaidongKTV/1.2")
// 自建服务器也接受 X-App-Token(与 body 里的 push_token 任一即可)。
if (!appToken.isNullOrEmpty()) conn.setRequestProperty(AppToken.HEADER, appToken)
conn.outputStream.use { it.write(form.toByteArray(Charsets.UTF_8)) }
val code = conn.responseCode
// 响应体只在失败时用来写日志;**绝不打印 form**(里面有推送令牌与媒体地址的 token)
@@ -75,10 +94,10 @@ object PushDownloadClient {
if (code !in 200..299) {
MiliLog.localWarn("PUSH-DL 推送失败 端点=$endpoint HTTP $code ${body?.take(120).orEmpty()}")
}
code in 200..299
TokenHttp(code, body.orEmpty())
} catch (t: Throwable) {
MiliLog.localWarn("PUSH-DL 推送失败 端点=$endpoint ${t.javaClass.simpleName}: ${t.message}")
false
TokenHttp(0, "", t.message)
} finally {
runCatching { conn?.disconnect() }
}
@@ -26,9 +26,12 @@ object SongApiClient {
bridge = KtvJsBridge(context)
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
// 第二参是自建源的 App 令牌(官方源传 null,请求形态与改造前逐字节一致)。
SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) }
// api.php 取配置走 POST(原版形态);桥不可用时为 null,此时跳过配置拉取。
SongUrlResolver.installPost { url, form -> bridge?.rawCleanPost(url, form) }
// App 令牌(自建源 `GET /token`)也走同一条干净 GET:与取址共用缓存与"该源不支持"记忆。
AppTokenClient.install { url -> bridge?.rawCleanGet(url, null, AppToken.TIMEOUT_MS) }
bridge?.init { ok ->
ready = ok
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
@@ -161,11 +164,15 @@ object SongApiClient {
* ⚠️ host 一律用 [AdVideoFilter.hostOf](**已剥离 query/fragment,且 URI 解析失败会退化**):
* 旧实现直接 `java.net.URI(url).host`,query 里出现 `|`/空格这类字符时 `URI()` 抛异常,
* 被 `?: return true` 当成 demo —— 合法真源地址会因此被拒("取址失败"的隐蔽来源)。
*
* ⚠️ 裸 IP 不再一律判 demo:命中媒体地址允许清单([AdVideoFilter.isAllowedMediaHost])时
* 放行 —— 直连内网自建源时 DB 里的 `cloud_url` 就是裸 IP。
*/
private fun isLikelyDemoUrl(url: String): Boolean {
val host = AdVideoFilter.hostOf(url) ?: return true
// 纯 IP 地址 → 高概率 demo
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"))) return true
// 纯 IP 地址 → 高概率 demo(**允许清单里的主机除外**)
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")) &&
!AdVideoFilter.isAllowedMediaHost(host)) return true
// Cloudflare R2 demo bucket (pub-*.r2.dev)
if (host.matches(Regex("^pub-[a-z0-9]+\\.r2\\.dev$"))) return true
return false
@@ -50,21 +50,30 @@ object SongUrlResolver {
private const val CONFIG_TTL_MS = 30 * 60_000L
/**
* 歌曲源入口(**2026-10-09 起只保留这一个**)。
* 歌曲源入口默认值:**自建源优先、官方源兜底**(2026-10-14)。
*
* `zk.ktv1.top:6677` 实测与 `e.ac19.cn` **同一个后台**(`i.php` 响应逐字段一致、
* `do.php` 同款 SUCCESS + 同一条真源 CDN 路径),且能连续下发**文件名=编号**的真源地址;
* 而其它入口要么只发广告/测试资源,要么整体 403。故按用户要求**清空其余入口**,
* 只留它一个(少扫源同时也避免了把同后台节点的额度一起打光)。
* * `https://mdmuse.kasugano.cn` —— 自建歌曲源(IIS 反代 HTTPS;媒体站 `md.kasugano.cn:4433`,
* 直连内网时是 `http://192.168.100.232:6677`,媒体地址为 `/media/<编号>.ts`);
* * `http://zk.ktv1.top:6677` —— 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。
*
* ⚠️ 与 `assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(及 `CONFIG.HOST`)**逐项一致**;
* 运行时可被设置项 `KtvStore.sourceHosts` 覆盖(见 [applySourceHosts]),两份实现共读同一份设置。
*/
private val DEFAULT_HOSTS = listOf(
"https://mdmuse.kasugano.cn",
"http://zk.ktv1.top:6677",
)
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
@Volatile private var fetch: ((String) -> String?)? = null
/**
* 干净请求通道:`(url, appToken) -> {"status":..,"body":..,"error":..}`。
*
* `appToken` 为空时**一个字节都不多加**(官方源的请求形态完全不变);
* 自建源由 [AppTokenClient] 取到令牌后经它带上 `X-App-Token`。
*/
@Volatile private var fetch: ((String, String?) -> String?)? = null
/** 干净 POST 通道(`api.php` 取配置用;未注入时跳过配置拉取)。 */
@Volatile private var post: ((String, String) -> String?)? = null
@@ -115,13 +124,32 @@ object SongUrlResolver {
/**
* 安装取址通道。
* @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]);
* 入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。
* 入参 `(url, appToken)`,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。
* `appToken` 为空表示不带 `X-App-Token`(官方源/令牌未取到时就是这种)。
*/
fun install(fetch: (String) -> String?) {
fun install(fetch: (String, String?) -> String?) {
this.fetch = fetch
Log.i(TAG, "native song url resolver installed (${DEFAULT_HOSTS.size} hosts)")
}
/**
* 用设置项里的源列表重置运行时优先级(App 启动 / 设置页保存时调用)。
*
* 列表会先归一化(去空白/尾斜杠、只留 http(s)、去重),**空列表回落 [SourceHosts.DEFAULT]**
* —— 设置被清空时不会变成"没有源可用"。
*/
fun applySourceHosts(hosts: List<String>?) {
val list = SourceHosts.parseList(hosts)
synchronized(hostOrder) {
hostOrder.clear()
hostOrder.addAll(list)
}
MiliLog.local("SOURCE-HOSTS 歌曲源已更新(${list.size} 个): ${list.joinToString(" > ")}")
}
/** 当前运行时源列表(只读快照,供日志/自检)。 */
fun currentHosts(): List<String> = synchronized(hostOrder) { hostOrder.toList() }
private fun demote(host: String) {
synchronized(hostOrder) {
val index = hostOrder.indexOf(host)
@@ -293,10 +321,12 @@ object SongUrlResolver {
// ─── 协议实现 ───────────────────────────────────────
private fun fetchToken(channel: (String) -> String?, host: String, mac: String): String? {
private fun fetchToken(channel: (String, String?) -> String?, host: String, mac: String): String? {
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 同处注释)
@@ -308,7 +338,7 @@ object SongUrlResolver {
}
private fun fetchSongUrl(
channel: (String) -> String?,
channel: (String, String?) -> String?,
host: String,
mac: String,
musicno: String,
@@ -321,7 +351,19 @@ object SongUrlResolver {
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)
val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null
val url = "$host/music/do.php?$params&sign=$sign"
// App 令牌(自建源)**自适应**:先对当前源试一次 `GET /token`(原生侧缓存),
// 取到就带 `X-App-Token`;官方源返回 404/405 会被记住"不支持令牌",之后不再尝试、也不带该头
// (避免每首歌白多一次请求)。带令牌被拒(401 + 令牌无效或已过期)→ 清缓存重取,
// **只重试一次** —— 全部逻辑见 [AppTokenSession](纯 JVM,有单测)。
val resp = AppTokenClient.withToken(host) { appToken -> callEnvelope(channel, url, appToken) }
if (resp.status !in 200..299) return null // callEnvelope 已记日志(含 401 令牌被拒)
val body = resp.body
if (body.isBlank()) return null
val json = runCatching { JSONObject(body) }.getOrElse {
MiliLog.localWarn("NATIVE-SONG 响应非 JSON: ${body.take(120)}")
return null
}
val code = json.optInt("code")
if (code != 200) {
// ⚠️ 源站的"风控拒绝"是 **HTTP 200 + 业务码 403**(body:
@@ -335,10 +377,20 @@ object SongUrlResolver {
return json.optString("data").takeIf { it.isNotEmpty() }
}
/** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */
private fun getJson(channel: (String) -> String?, url: String): JSONObject? {
val raw = channel(url) ?: return null
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null
/**
* 底层一次请求:解出 `request()` 的信封 `{status, body, error}`(纯数据 [TokenHttp],
* 这样令牌会话才能据 HTTP 状态判定"401 = 令牌被拒")。
*
* 非 2xx 在这里统一记日志(403 另置 [sawForbidden],供"全源失败 → 换公网 IP"提示)。
*/
private fun callEnvelope(
channel: (String, String?) -> String?,
url: String,
appToken: String?,
): TokenHttp {
val raw = runCatching { channel(url, appToken) }.getOrNull()
if (raw.isNullOrEmpty()) return TokenHttp(0, "", "no-response")
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return TokenHttp(0, "", "bad-envelope")
val status = envelope.optInt("status")
if (status !in 200..299) {
// 403 = 源站风控的直接信号(2026-10 实测主源 w.w345.my/i.php 就是 403)。
@@ -348,9 +400,15 @@ object SongUrlResolver {
"NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " +
"err=${envelope.optString("error").take(80)}",
)
return null
}
val body = envelope.optString("body")
return TokenHttp(status, envelope.optString("body"), envelope.optString("error"))
}
/** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */
private fun getJson(channel: (String, String?) -> String?, url: String): JSONObject? {
val resp = callEnvelope(channel, url, null)
if (resp.status !in 200..299) return null
val body = resp.body
if (body.isBlank()) return null
return runCatching { JSONObject(body) }.getOrElse {
MiliLog.localWarn("NATIVE-SONG 响应非 JSON: ${body.take(120)}")
@@ -363,6 +421,10 @@ object SongUrlResolver {
* 已涵盖原版 `isDemoUrl` 的全部规则(wb66/demo、`*.ac16.vip`、`/ad_files/`、`my_ad_video`、
* 裸 IP、`pub-*.r2.dev/ss/`、`.ts` 文件名必须等于点歌编号)。
*
* ⚠️ 裸 IP 现在有**豁免通道**:主机命中媒体地址允许清单(`AdVideoFilter.allowedMediaHosts`,
* 默认 `md.kasugano.cn` + `192.168.100.232`)时放行 —— 直连内网自建源时媒体地址就是裸 IP
* (`http://192.168.100.232:6677/media/<编号>.ts`)。其余规则与黑名单优先级都不变。
*
* ⚠️ 这些规则只在 **host + path** 上判定(`AdVideoFilter.hostOf/pathOf` 已剥离
* query/fragment):真源地址带 `?token=…&t=…`(2026-10-11 抓包),拿整串搜 `wb66`/`demo`
* 会把 query 里的签名/随机串当广告特征(误杀真源)—— 这正是"地址不可信"的隐蔽来源之一。
@@ -0,0 +1,139 @@
package com.local.ktv
import java.util.LinkedHashSet
/**
* 歌曲源列表 / 「媒体地址允许清单」的**纯逻辑**(不碰 Android API,可跑纯 JVM 单测)。
*
* ## 为什么单独放一处
* 源列表有两份实现(JS 主路径 `ktv_api.js` 的 `CONFIG.HOSTS` + 原生兜底 [SongUrlResolver]),
* 两边的**默认值必须逐项一致**、且都要能被设置项覆盖(见 [KtvStore.sourceHosts])。
* 解析/归一化这类"没有 IO 的判断"全部收敛到这里,JS 侧只负责"接收注入的数组"。
*
* ## 2026-10-14 自建源上线后的默认值
* 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**排在第一位**,
* 官方源 `http://zk.ktv1.top:6677` 兜底 —— 自建源取不到地址时仍能回落到官方源,不影响老用户。
*
* ⚠️ 允许清单([DEFAULT_ALLOWED_MEDIA_HOSTS])只放行"**必须是域名**"这一条限制
* (直连内网自建源时媒体地址就是裸 IP:`http://192.168.100.232:6677/media/<编号>.ts`);
* 广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss)与 `.ts` 文件名比对
* **优先级更高**,命中允许清单也要拒(见 [AdVideoFilter.isTrustedSongUrl])。
*/
object SourceHosts {
/**
* 默认源入口列表:**自建源优先、官方源兜底**。
*
* ⚠️ 与 `app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(以及 `CONFIG.HOST`)
* **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本)。
*/
@JvmField
val DEFAULT = listOf(
"https://mdmuse.kasugano.cn",
"http://zk.ktv1.top:6677",
)
/** 自建源的**主机名**(App 令牌 `/token` 与「点歌推送」的宿主,见 [PushDownload.effectiveServerUrl])。 */
const val SELF_HOST = "mdmuse.kasugano.cn"
/**
* 自建源的**内网直连主机**(同一个服务端的另一种入口:`http://192.168.100.232:6677`)。
*
* 直连内网时媒体地址是裸 IP(`http://192.168.100.232:6677/media/<编号>.ts`),
* 因此它同时也在默认媒体允许清单里。
*/
const val SELF_HOST_LAN = "192.168.100.232"
/**
* 默认「媒体地址允许清单」:只放行这些主机的**非域名**限制。
*
* * `md.kasugano.cn` —— 自建源的媒体站(HTTPS 反代,`https://md.kasugano.cn:4433/media/<编号>.ts`);
* * `192.168.100.232` —— 直连内网自建源时服务端下发的裸 IP 媒体地址。
*/
@JvmField
val DEFAULT_ALLOWED_MEDIA_HOSTS = listOf("md.kasugano.cn", "192.168.100.232")
/** 文本里的分隔符:换行、逗号、分号、空白(设置界面按"一行一个"编辑)。 */
private val SEPARATORS = Regex("[\\s,;]+")
/** 从**原始文本**解析源列表(一行一个;空 → [DEFAULT])。 */
@JvmStatic
fun parse(text: String?): List<String> = parseList(text?.split(SEPARATORS))
/**
* 归一化"源入口"列表:去空白/去尾斜杠、只留 `http(s)://…`、按出现顺序去重;
* **空列表 → [DEFAULT]**(设置项被清空时不至于没有源可用)。
*/
@JvmStatic
fun parseList(items: Collection<String>?): List<String> {
val out = LinkedHashSet<String>()
items?.forEach { raw -> normalizeHost(raw)?.let { out += it } }
return if (out.isEmpty()) DEFAULT else out.toList()
}
/** 归一化单个源入口;空 / 非 http(s)(配置写错)时返回 null(按"忽略"处理)。 */
@JvmStatic
fun normalizeHost(raw: String?): String? {
val text = raw?.trim()?.trimEnd('/').orEmpty()
if (text.isEmpty()) return null
if (!text.startsWith("http://") && !text.startsWith("https://")) return null
return text
}
/** 从**原始文本**解析允许清单(一行一个;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS])。 */
@JvmStatic
fun parseAllowed(text: String?): List<String> = parseAllowedList(text?.split(SEPARATORS))
/** 归一化整份允许清单;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS]。 */
@JvmStatic
fun parseAllowedList(items: Collection<String>?): List<String> {
val out = LinkedHashSet<String>()
items?.forEach { raw -> normalizeAllowedHost(raw)?.let { out += it } }
return if (out.isEmpty()) DEFAULT_ALLOWED_MEDIA_HOSTS else out.toList()
}
/**
* 归一化允许清单条目 → **纯主机名**(小写、无端口/路径)。
*
* 设置界面里用户可能整条粘贴(`https://md.kasugano.cn:4433/media`)或只写主机
* (`192.168.100.232`),两种都要能认。
*/
@JvmStatic
fun normalizeAllowedHost(raw: String?): String? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
var host = text.substringAfter("://", text)
host = host.substringBefore('/').substringBefore('?').substringBefore('#')
host = host.substringAfterLast('@')
host = if (host.startsWith("[")) host.substringBefore(']') // IPv6 字面量
else host.substringBefore(':')
return host.trim().lowercase().ifEmpty { null }
}
/**
* 该源入口是不是**自建源**(只看主机名,与 scheme/端口无关):
* 公网 API 站 `mdmuse.kasugano.cn` 与内网直连 `192.168.100.232` 都是同一个服务端。
*/
@JvmStatic
fun isSelfHost(url: String?): Boolean {
val host = hostOf(url) ?: return false
return host == SELF_HOST || host == SELF_HOST_LAN
}
/**
* 取 URL 的 **host**(小写,不含端口);解析不出来返回 null。
*
* 与 [AdVideoFilter.hostOf] 同口径的手写解析(**不用 `java.net.URI`**):
* query 里带 `|`/空格这类字符时 `URI()` 会抛异常,而这里只需要 host。
*/
@JvmStatic
fun hostOf(url: String?): String? {
val text = url?.trim().orEmpty()
if (text.isEmpty()) return null
val head = text.substringBefore('#').substringBefore('?')
val match = Regex("^[a-zA-Z][a-zA-Z0-9+.\\-]*://([^/]*)").find(head) ?: return null
var host = match.groupValues.getOrNull(1).orEmpty().substringAfterLast('@')
host = if (host.startsWith("[")) host.substringBefore(']') else host.substringBefore(':')
return host.lowercase().ifEmpty { null }
}
}
@@ -0,0 +1,232 @@
package com.local.ktv;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.concurrent.atomic.AtomicLong;
import org.junit.Test;
/**
* App 令牌(自建源 `GET /token`)纯 JVM 单测。
*
* 覆盖需求点名的五件事:
* ① 首次取令牌;
* ② 未过期**复用**(不再请求 `/token`);
* ③ 过期后**重取**;
* ④ 404/405 → 记住"该源不支持令牌",之后**不再尝试**;
* ⑤ 带令牌被拒(401 + `令牌无效或已过期`)→ 清缓存、重取、**只重试一次**。
*
* 另附:官方源(不带令牌)的 401 不该被当成"我们的令牌被拒"(不该清缓存/重试)。
*/
public class AppTokenTest {
/** 可注入时钟 + 记录请求次数的假 `/token` 服务。 */
private static final class FakeTokenServer {
final AtomicLong now = new AtomicLong(1_700_000_000_000L);
final java.util.List<String> requested = new java.util.ArrayList<String>();
volatile TokenHttp response = new TokenHttp(200, "{\"ok\":true,\"token\":\"tk-1\",\"expires_in\":600}");
AppTokenSession session() {
return new AppTokenSession(url -> {
requested.add(url);
return response;
}, now::get);
}
int calls() {
return requested.size();
}
}
// ── ① 首次取 ──
@Test
public void fetchesTokenOnFirstUse() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
assertEquals("tk-1", session.tokenFor("https://mdmuse.kasugano.cn"));
assertEquals(1, server.calls());
assertEquals("https://mdmuse.kasugano.cn/token", server.requested.get(0));
assertTrue(session.hasFreshToken("https://mdmuse.kasugano.cn"));
}
// ── ② 未过期复用 ──
@Test
public void reusesTokenBeforeExpiry() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
assertEquals("tk-1", session.tokenFor("https://mdmuse.kasugano.cn"));
server.response = new TokenHttp(200, "{\"ok\":true,\"token\":\"tk-2\",\"expires_in\":600}");
// 提前 30 秒过期:还没到(600-30=570 秒)→ 必须复用缓存,再问也不该多打一次 /token
server.now.addAndGet(500_000L);
assertEquals("tk-1", session.tokenFor("https://mdmuse.kasugano.cn"));
assertEquals("未过期的令牌必须复用缓存", 1, server.calls());
}
// ── ③ 过期重取 ──
@Test
public void refetchesAfterExpiry() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
assertEquals("tk-1", session.tokenFor("https://mdmuse.kasugano.cn"));
server.response = new TokenHttp(200, "{\"ok\":true,\"token\":\"tk-2\",\"expires_in\":600}");
// 600-30=570 秒后即视为过期(提前 30 秒)
server.now.addAndGet(571_000L);
assertEquals("tk-2", session.tokenFor("https://mdmuse.kasugano.cn"));
assertEquals(2, server.calls());
assertTrue("重取后缓存里是新令牌", session.hasFreshToken("https://mdmuse.kasugano.cn"));
}
@Test
public void missingExpiresInFallsBackToDefaultTtl() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
server.response = new TokenHttp(200, "{\"token\":\"tk-x\"}");
assertEquals("tk-x", session.tokenFor("https://mdmuse.kasugano.cn"));
server.now.addAndGet(AppToken.DEFAULT_EXPIRES_IN_SEC * 1000L - AppToken.EARLY_EXPIRE_MS - 1);
assertEquals("tk-x", session.tokenFor("https://mdmuse.kasugano.cn"));
assertEquals(1, server.calls());
}
// ── ④ 404/405 → 记住"不支持",之后不再尝试 ──
@Test
public void unsupportedSourceIsRememberedAndNotRetried() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
server.response = new TokenHttp(404, "{\"error\":\"not found\"}");
assertNull(session.tokenFor("http://zk.ktv1.top:6677"));
assertTrue(session.isUnsupported("http://zk.ktv1.top:6677"));
// 之后再问多少次都不该再发请求(官方源上的"每首歌白多一次请求"就是这么避免的)
assertNull(session.tokenFor("http://zk.ktv1.top:6677"));
assertNull(session.tokenFor("http://zk.ktv1.top:6677"));
assertEquals(1, server.calls());
assertEquals(1, session.fetchCount("http://zk.ktv1.top:6677"));
// 405 同样按"不支持"处理
FakeTokenServer server2 = new FakeTokenServer();
AppTokenSession session2 = server2.session();
server2.response = new TokenHttp(405, "");
assertNull(session2.tokenFor("http://official.example.com"));
assertNull(session2.tokenFor("http://official.example.com"));
assertEquals(1, server2.calls());
}
@Test
public void transientFailureBacksOffThenRecovers() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
// 网络异常(生产里 = TokenHttp(0, …)):不带令牌,但**不能**记成"不支持"
server.response = new TokenHttp(0, "", "timeout");
assertNull(session.tokenFor("https://mdmuse.kasugano.cn"));
assertFalse(session.isUnsupported("https://mdmuse.kasugano.cn"));
assertEquals(1, server.calls());
// 冷却期内不再请求;冷却过后自动恢复
assertNull(session.tokenFor("https://mdmuse.kasugano.cn"));
assertEquals(1, server.calls());
server.now.addAndGet(AppToken.FAILURE_BACKOFF_MS + 1);
server.response = new TokenHttp(200, "{\"token\":\"tk-3\",\"expires_in\":600}");
assertEquals("tk-3", session.tokenFor("https://mdmuse.kasugano.cn"));
assertEquals(2, server.calls());
}
// ── ⑤ 401 → 清缓存、重取、只重试一次 ──
@Test
public void unauthorizedClearsCacheRefetchesAndRetriesExactlyOnce() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
// 第一次拿 tk-1;服务端把它作废后,带 tk-1 的请求返回 401
server.response = new TokenHttp(200, "{\"token\":\"tk-1\",\"expires_in\":600}");
assertEquals("tk-1", session.tokenFor("https://mdmuse.kasugano.cn"));
server.response = new TokenHttp(200, "{\"token\":\"tk-2\",\"expires_in\":600}");
final java.util.List<String> sentTokens = new java.util.ArrayList<String>();
TokenHttp result = session.withTokenHttp("https://mdmuse.kasugano.cn", token -> {
sentTokens.add(token);
// 用 tk-1 会被拒(401 + 服务端文案),用 tk-2 成功
return "tk-1".equals(token)
? new TokenHttp(401, "{\"ok\":false,\"error\":\"令牌无效或已过期,请重新获取\"}")
: new TokenHttp(200, "{\"code\":200,\"msg\":\"SUCCESS\"}");
});
assertEquals(200, result.status);
assertEquals("只允许发出两次请求(首次 + 重试一次)", 2, sentTokens.size());
assertEquals("tk-1", sentTokens.get(0));
assertEquals("tk-2", sentTokens.get(1));
assertEquals("401 后必须清缓存并重取一次令牌", 2, server.calls());
}
@Test
public void unauthorizedRetryHappensOnlyOnceEvenIfStillRejected() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
server.response = new TokenHttp(200, "{\"token\":\"tk-1\",\"expires_in\":600}");
assertEquals("tk-1", session.tokenFor("https://mdmuse.kasugano.cn"));
final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
TokenHttp result = session.withTokenHttp("https://mdmuse.kasugano.cn", token -> {
calls.incrementAndGet();
return new TokenHttp(401, "{\"ok\":false,\"error\":\"令牌无效或已过期,请重新获取\"}");
});
assertEquals(401, result.status);
assertEquals("一直 401 也只重试一次,不能变成重试风暴", 2, calls.get());
assertEquals("重试前重取了一次令牌", 2, server.calls());
}
@Test
public void unauthorizedWithoutTokenIsNotTreatedAsTokenRejection() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
// 官方源:/token 404 → 不带令牌;此时源站自己的 401 与我们的令牌无关,**不能**清缓存/重试
server.response = new TokenHttp(404, "");
final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
TokenHttp result = session.withTokenHttp("http://zk.ktv1.top:6677", token -> {
calls.incrementAndGet();
assertNull("不支持令牌的源不该带 X-App-Token", token);
return new TokenHttp(401, "unauthorized");
});
assertEquals(401, result.status);
assertEquals(1, calls.get());
assertEquals("不能因为源站自己的 401 再去打 /token", 1, server.calls());
}
@Test
public void bodyMarkerWithout401IsAlsoTreatedAsRejection() {
FakeTokenServer server = new FakeTokenServer();
AppTokenSession session = server.session();
server.response = new TokenHttp(200, "{\"token\":\"tk-1\",\"expires_in\":600}");
final java.util.concurrent.atomic.AtomicInteger calls = new java.util.concurrent.atomic.AtomicInteger();
TokenHttp result = session.withTokenHttp("https://mdmuse.kasugano.cn", token -> {
calls.incrementAndGet();
// 反代把 401 包装成 200 的情况:按服务端文案兜底
return new TokenHttp(200, "{\"ok\":false,\"error\":\"令牌无效或已过期,请重新获取\"}");
});
assertEquals(200, result.status);
assertEquals(2, calls.get());
assertEquals("判定为令牌被拒 → 清缓存并重取一次", 2, server.calls());
}
// ── 解析工具 ──
@Test
public void parsersReadServerContract() {
String body = "{\"ok\":true,\"token\":\"abc-DEF_123\",\"expires_in\":600,\"expires_at\":1791706901}";
assertEquals("abc-DEF_123", AppToken.parseToken(body));
assertEquals(Long.valueOf(600L), AppToken.parseExpiresIn(body));
assertEquals("https://mdmuse.kasugano.cn/token", AppToken.endpoint("https://mdmuse.kasugano.cn/"));
assertTrue(AppToken.isUnsupportedStatus(404));
assertTrue(AppToken.isUnsupportedStatus(405));
assertFalse(AppToken.isUnsupportedStatus(200));
assertTrue(AppToken.isUnauthorized(401, ""));
assertTrue(AppToken.isUnauthorized(200, "令牌无效或已过期,请重新获取"));
assertFalse(AppToken.isUnauthorized(403, "请求被拒绝:检测到异常行为"));
assertNull(AppToken.parseToken("{\"ok\":false}"));
}
}
@@ -0,0 +1,143 @@
package com.local.ktv;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Test;
/**
* 「媒体地址允许清单」闸门回归测试(2026-10-14 自建源接入)。
*
* 需求点名的四条:
* 1. 命中 `allowedMediaHosts` 的**裸 IP** 媒体地址 → **通过**;
* 2. 不在清单的裸 IP → **拒绝**(不是"放行任意裸 IP");
* 3. `wb66` 等黑名单 → **即使主机在清单里也拒绝**(黑名单优先级最高);
* 4. `.ts` 文件名 ≠ 编号 → 仍然拒绝(允许清单不放宽这条)。
*/
public final class MediaHostAllowlistTest {
/** 自建源的两种真实媒体地址形态(HTTPS 媒体站 / 直连内网裸 IP)。 */
private static final String MEDIA_HTTPS = "https://md.kasugano.cn:4433/media/7303235.ts";
private static final String MEDIA_LAN = "http://192.168.100.232:6677/media/7303235.ts";
private static final List<String> DEFAULT_ALLOWED =
Arrays.asList("md.kasugano.cn", "192.168.100.232");
// ── 1. 命中清单的裸 IP → 通过 ──
@Test
public void allowlistedBareIpMediaUrlPasses() {
assertTrue(AdVideoFilter.isTrustedSongUrl(MEDIA_LAN, "7303235", DEFAULT_ALLOWED));
// 带 query 也一样(媒体站可能带签名)
assertTrue(AdVideoFilter.isTrustedSongUrl(
MEDIA_LAN + "?token=abc&t=1791706901", "7303235", DEFAULT_ALLOWED));
// 媒体站域名(本来就不是裸 IP,也不能被清单搞坏)
assertTrue(AdVideoFilter.isTrustedSongUrl(MEDIA_HTTPS, "7303235", DEFAULT_ALLOWED));
// 清单里的域名:不带 .ts 也放行(沿用旧规则:没有 .ts 就不比对文件名)
assertTrue(AdVideoFilter.isTrustedSongUrl(
"https://md.kasugano.cn:4433/media/stream?token=x", "7303235", DEFAULT_ALLOWED));
}
/** 全局设置项版本:装载后同样生效(生产路径走的就是它)。 */
@Test
public void globalAllowlistIsUsedByTwoArgGate() {
java.util.Set<String> saved = AdVideoFilter.allowedMediaHosts;
try {
AdVideoFilter.setAllowedMediaHosts(DEFAULT_ALLOWED);
assertTrue(AdVideoFilter.isAllowedMediaHost("192.168.100.232"));
assertTrue(AdVideoFilter.isAllowedMediaHost("MD.Kasugano.cn"));
assertFalse(AdVideoFilter.isAllowedMediaHost("10.0.0.9"));
assertTrue(AdVideoFilter.isTrustedSongUrl(MEDIA_LAN, "7303235"));
AdVideoFilter.setAllowedMediaHosts(Collections.<String>emptyList()); // 空 → 回落默认
assertTrue(AdVideoFilter.isTrustedSongUrl(MEDIA_LAN, "7303235"));
} finally {
AdVideoFilter.allowedMediaHosts = saved;
}
}
// ── 2. 不在清单的裸 IP → 拒绝 ──
@Test
public void bareIpNotInListStillRejected() {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://10.0.0.9:6677/media/7303235.ts", "7303235", DEFAULT_ALLOWED));
// 清单为空(= 不豁免任何主机)时同样拒
assertFalse(AdVideoFilter.isTrustedSongUrl(MEDIA_LAN, "7303235",
Collections.<String>emptyList()));
// 子域匹配是"清单项的 .子域",不能反过来(192.168.100.232 不是 x.192.168.100.232 的后缀)
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://192.168.100.233:6677/media/7303235.ts", "7303235", DEFAULT_ALLOWED));
}
// ── 3. 黑名单优先级最高:命中清单也要拒 ──
@Test
public void blacklistWinsOverAllowlist() {
List<String> wideOpen = Arrays.asList("wb66.cn", "demo.example.com", "gz.ac16.vip", "md.kasugano.cn");
// wb66 域名特征
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/7303235.ts", "7303235", wideOpen));
// demo 关键字(host 上)
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://demo.example.com/x/7303235.ts", "7303235", wideOpen));
// ac16.vip 广告域名
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://gz.ac16.vip/ad/7303235.ts", "7303235", wideOpen));
// 路径特征 /ad_files/、my_ad_video(即使主机命中清单)
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://md.kasugano.cn/ad_files/my_ad_video.ts", "7303235", DEFAULT_ALLOWED));
// 文件名 = 编号 也没用:黑名单先命中
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/7303235.ts?token=x", "7303235", wideOpen));
}
// ── 4. `.ts` 文件名必须等于编号(允许清单不放宽) ──
@Test
public void filenameRuleStillAppliesOnAllowlistedHosts() {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://192.168.100.232:6677/media/9999999.ts", "7303235", DEFAULT_ALLOWED));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://md.kasugano.cn:4433/media/promo.ts", "7303235", DEFAULT_ALLOWED));
assertFalse(AdVideoFilter.isTrustedSongUrl(
"http://192.168.100.232:6677/media/9999999.ts?token=x#frag", "7303235", DEFAULT_ALLOWED));
// 正确编号(带 query/fragment)→ 放行
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://192.168.100.232:6677/media/7303235.ts?token=x#frag", "7303235", DEFAULT_ALLOWED));
}
/** R2 测试桶是黑名单,不受允许清单影响(用户把 r2.dev 写进清单也不放行 /ss/)。 */
@Test
public void r2TestBucketStillRejected() {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://pub-abc123.r2.dev/ss/7303235.ts", "7303235",
Arrays.asList("pub-abc123.r2.dev")));
}
/** 非 http(s) / 空值照旧拒绝(清单不改变这条)。 */
@Test
public void nonHttpStillRejected() {
assertFalse(AdVideoFilter.isTrustedSongUrl("file:///sdcard/192.168.100.232/7303235.ts",
"7303235", DEFAULT_ALLOWED));
assertFalse(AdVideoFilter.isTrustedSongUrl(null, "7303235", DEFAULT_ALLOWED));
assertFalse(AdVideoFilter.isTrustedSongUrl("", "7303235", DEFAULT_ALLOWED));
}
/** 原有真源(两个 CDN 域)不受影响:默认清单下依然放行。 */
@Test
public void officialRealSourcesStillPass() {
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://txog.ktvsky.com/264/lvbr/480/E/ts/47.1/202209220333/480p/7303235.ts?token=526e505e",
"7303235", DEFAULT_ALLOWED));
assertTrue(AdVideoFilter.isTrustedSongUrl(
"http://download.origjoy.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7445418.ts?token=abc",
"7445418", DEFAULT_ALLOWED));
// 未命中清单的真实广告仍拒
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/10188555.ts", "7303235", DEFAULT_ALLOWED));
}
}
@@ -0,0 +1,129 @@
package com.local.ktv;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.junit.Test;
/**
* 歌曲源列表 / 媒体地址允许清单 / 推送默认目标的纯 JVM 单测。
*
* 覆盖需求点名的两组:
* ② 源列表解析(`sourceHosts` 为空时回落默认;含自建源优先);
* ④ 推送默认目标(`pushServerUrl` 为空且首源为自建源时使用它,否则尊重显式配置)。
*/
public class SourceHostsTest {
// ── ② 源列表解析 ──
/** 空(旧配置没有这个键 / 用户清空)→ 回落默认:**自建源第一、官方源兜底**。 */
@Test
public void emptySourceListFallsBackToDefaultWithSelfHostFirst() {
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(null));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(""));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(" \n \n"));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(null));
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(Collections.<String>emptyList()));
assertEquals("https://mdmuse.kasugano.cn", SourceHosts.DEFAULT.get(0));
assertEquals("http://zk.ktv1.top:6677", SourceHosts.DEFAULT.get(1));
assertTrue("默认列表里自建源必须排第一", SourceHosts.isSelfHost(SourceHosts.DEFAULT.get(0)));
}
/** 设置页"一行一个":去空白/尾斜杠、按顺序去重、忽略非 http(s) 的错行。 */
@Test
public void parsesMultilineTextInOrder() {
List<String> hosts = SourceHosts.parse(
" https://mdmuse.kasugano.cn/ \n"
+ "\n"
+ "http://192.168.100.232:6677 \n"
+ "zk.ktv1.top:6677\n" // 没写协议 → 忽略
+ "https://mdmuse.kasugano.cn\n"); // 重复 → 去重
assertEquals(Arrays.asList(
"https://mdmuse.kasugano.cn",
"http://192.168.100.232:6677"), hosts);
// 逗号/分号也当分隔符(便于整条粘贴)
assertEquals(2, SourceHosts.parse("https://a.example.com, http://b.example.com").size());
assertEquals(2, SourceHosts.parse("https://a.example.com;http://b.example.com").size());
}
/** 自建源优先:自建源放第一 + 官方源兜底,两份实现(JS/原生)都读这个顺序。 */
@Test
public void selfHostFirstThenOfficialFallback() {
List<String> hosts = SourceHosts.parseList(Arrays.asList(
"https://mdmuse.kasugano.cn",
"http://zk.ktv1.top:6677"));
assertTrue(SourceHosts.isSelfHost(hosts.get(0)));
assertFalse(SourceHosts.isSelfHost(hosts.get(1)));
// 主机名判定与 scheme/端口无关(内网直连也是自建源)
assertTrue(SourceHosts.isSelfHost("http://mdmuse.kasugano.cn:6677"));
assertFalse(SourceHosts.isSelfHost(null));
assertFalse(SourceHosts.isSelfHost(""));
}
// ── 允许清单解析 ──
@Test
public void allowedMediaHostsDefaultAndNormalization() {
assertEquals(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS, SourceHosts.parseAllowed(null));
assertEquals(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS, SourceHosts.parseAllowed(" "));
assertTrue(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.contains("md.kasugano.cn"));
assertTrue(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.contains("192.168.100.232"));
// 整条粘贴 / 大写 / 端口 都要能归一化成主机名
assertEquals(Arrays.asList("md.kasugano.cn", "192.168.100.232"),
SourceHosts.parseAllowed("https://MD.Kasugano.cn:4433/media\n192.168.100.232:6677"));
assertEquals("md.kasugano.cn", SourceHosts.normalizeAllowedHost(" md.kasugano.cn "));
assertNull(SourceHosts.normalizeAllowedHost(" "));
}
// ── ④ 推送默认目标 ──
@Test
public void pushTargetAutoUsesFirstSelfHostSource() {
// 留空 + 首源是自建源 → 用它(自建源接入后的默认行为)
assertEquals("https://mdmuse.kasugano.cn",
PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT));
assertEquals("http://192.168.100.232:6677",
PushDownload.effectiveServerUrl(null, Collections.singletonList("http://192.168.100.232:6677")));
// 空配置也照样能拼出端点
assertEquals("https://mdmuse.kasugano.cn/push_download",
PushDownload.endpoint(PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT)));
}
@Test
public void pushTargetRespectsExplicitConfigAndOff() {
// 显式覆盖优先(现有用户配过地址的完全不受影响)
assertEquals("http://192.168.1.10:6677",
PushDownload.effectiveServerUrl(" http://192.168.1.10:6677 ", SourceHosts.DEFAULT));
assertEquals("http://192.168.1.10:6677/push_download",
PushDownload.endpoint(PushDownload.effectiveServerUrl("http://192.168.1.10:6677", SourceHosts.DEFAULT)));
// 显式关闭:留空已被"自动"占用,所以用 off/none/关闭 明确关掉
for (String off : new String[]{"off", "OFF", "none", "no", "0", "false", "关闭", "禁用"}) {
assertEquals("显式关闭必须为空: " + off, "",
PushDownload.effectiveServerUrl(off, SourceHosts.DEFAULT));
assertNull("关闭时不能拼出端点", PushDownload.endpoint(
PushDownload.effectiveServerUrl(off, SourceHosts.DEFAULT)));
}
}
@Test
public void pushTargetStaysOffWhenFirstSourceIsOfficial() {
List<String> officialFirst = new ArrayList<String>();
officialFirst.add("http://zk.ktv1.top:6677");
officialFirst.add("https://mdmuse.kasugano.cn");
assertEquals("首源不是自建源 → 不自动开推送", "",
PushDownload.effectiveServerUrl("", officialFirst));
assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("", officialFirst)));
assertEquals("没有任何源 → 关闭", "",
PushDownload.effectiveServerUrl("", Collections.<String>emptyList()));
// 配置非法(漏写协议)仍按关闭处理(沿用旧口径)
assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("192.168.1.10:6677", SourceHosts.DEFAULT)));
}
}