接入自建歌曲源服务器:源列表自建优先 + 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:
+2
-2
@@ -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,否则
|
||||
|
||||
@@ -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`)。 */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 窗口守卫提示状态。
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user