From e00a1ace79bdc275bd07c99d697562f60e82f02d Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Wed, 14 Oct 2026 02:46:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E8=87=AA=E5=BB=BA=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E6=BA=90=E6=9C=8D=E5=8A=A1=E5=99=A8=EF=BC=9A=E6=BA=90?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E8=87=AA=E5=BB=BA=E4=BC=98=E5=85=88=20+=20Ap?= =?UTF-8?q?p=20=E4=BB=A4=E7=89=8C=20+=20=E5=AA=92=E4=BD=93=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=85=81=E8=AE=B8=E6=B8=85=E5=8D=95=20+=20=E6=8E=A8?= =?UTF-8?q?=E9=80=81=E9=BB=98=E8=AE=A4=E7=9B=AE=E6=A0=87=EF=BC=88V1.6.1/44?= =?UTF-8?q?=EF=BC=8C=E6=9C=AA=E5=8F=91=E5=B8=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 源列表(自建源优先、官方源兜底) - 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 例全过。 --- app/build.gradle | 4 +- app/src/main/assets/mobile/ktv_api.js | 110 +++++++-- .../main/java/com/local/ktv/AdVideoFilter.kt | 58 ++++- app/src/main/java/com/local/ktv/AppToken.kt | 211 ++++++++++++++++ .../main/java/com/local/ktv/AppTokenClient.kt | 84 +++++++ .../main/java/com/local/ktv/KtvJsBridge.kt | 86 ++++++- app/src/main/java/com/local/ktv/KtvStore.kt | 59 ++++- .../main/java/com/local/ktv/MainActivity.kt | 121 ++++++++- .../main/java/com/local/ktv/PushDownload.kt | 32 ++- .../java/com/local/ktv/PushDownloadClient.kt | 41 +++- .../main/java/com/local/ktv/SongApiClient.kt | 13 +- .../java/com/local/ktv/SongUrlResolver.kt | 96 ++++++-- .../main/java/com/local/ktv/SourceHosts.kt | 139 +++++++++++ .../test/java/com/local/ktv/AppTokenTest.java | 232 ++++++++++++++++++ .../com/local/ktv/MediaHostAllowlistTest.java | 143 +++++++++++ .../java/com/local/ktv/SourceHostsTest.java | 129 ++++++++++ 16 files changed, 1477 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/local/ktv/AppToken.kt create mode 100644 app/src/main/java/com/local/ktv/AppTokenClient.kt create mode 100644 app/src/main/java/com/local/ktv/SourceHosts.kt create mode 100644 app/src/test/java/com/local/ktv/AppTokenTest.java create mode 100644 app/src/test/java/com/local/ktv/MediaHostAllowlistTest.java create mode 100644 app/src/test/java/com/local/ktv/SourceHostsTest.java diff --git a/app/build.gradle b/app/build.gradle index 2279fc4..da477bf 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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,否则 diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js index c32c55d..1b8be40 100644 --- a/app/src/main/assets/mobile/ktv_api.js +++ b/app/src/main/assets/mobile/ktv_api.js @@ -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 恒为 /.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}; diff --git a/app/src/main/java/com/local/ktv/AdVideoFilter.kt b/app/src/main/java/com/local/ktv/AdVideoFilter.kt index 8a97d2c..f332c21 100644 --- a/app/src/main/java/com/local/ktv/AdVideoFilter.kt +++ b/app/src/main/java/com/local/ktv/AdVideoFilter.kt @@ -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 = SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.toSet() + + /** 装载允许清单(条目会被归一化成纯主机名,见 [SourceHosts.normalizeAllowedHost])。 */ + @JvmStatic + fun setAllowedMediaHosts(hosts: Collection?) { + 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?): 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`)。 */ diff --git a/app/src/main/java/com/local/ktv/AppToken.kt b/app/src/main/java/com/local/ktv/AppToken.kt new file mode 100644 index 0000000..d6d134b --- /dev/null +++ b/app/src/main/java/com/local/ktv/AppToken.kt @@ -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 { + /** 令牌接口路径(拼在**当前源**后面:`/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 = "令牌无效或已过期" + + /** 令牌接口完整地址(`/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() + /** 已确认"不支持令牌"的源(404/405):**永久记住**,之后连请求都不发。 */ + private val unsupported = HashSet() + /** 非 404/405 的失败 → 冷却到该时刻(避免每首歌都多打一次 /token)。 */ + private val retryAt = HashMap() + /** 单测用:每个 host 实际请求过几次 `/token`。 */ + private val fetches = HashMap() + + /** 单参构造:用系统时钟(生产路径)。 */ + 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 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() } +} diff --git a/app/src/main/java/com/local/ktv/AppTokenClient.kt b/app/src/main/java/com/local/ktv/AppTokenClient.kt new file mode 100644 index 0000000..3137534 --- /dev/null +++ b/app/src/main/java/com/local/ktv/AppTokenClient.kt @@ -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) + } +} diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index e00c73d..4ecf0ce 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -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=`;而 `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 = 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 = 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 窗口守卫提示状态。 diff --git a/app/src/main/java/com/local/ktv/KtvStore.kt b/app/src/main/java/com/local/ktv/KtvStore.kt index bec77fb..b6de24a 100644 --- a/app/src/main/java/com/local/ktv/KtvStore.kt +++ b/app/src/main/java/com/local/ktv/KtvStore.kt @@ -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 = SourceHosts.DEFAULT.toMutableList() + + /** + * **媒体地址允许清单**(默认 = [SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS]: + * `md.kasugano.cn` + `192.168.100.232`)。 + * + * 只放行"媒体地址必须是域名"这一条限制(直连内网自建源时是裸 IP); + * 广告黑名单与 `.ts` 文件名比对优先级更高,见 [AdVideoFilter.setAllowedMediaHosts]。 + */ + @JvmField val allowedMediaHosts: MutableList = + SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS.toMutableList() @JvmField val orderQueue = mutableListOf() @JvmField val sangHistory = mutableListOf() @JvmField val scoreHistory = mutableListOf() @@ -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 { + if (array == null) return emptyList() + val out = ArrayList(array.length()) + repeat(array.length()) { out += array.optString(it) } + return out + } + private fun writeSongs(songs: List): JSONArray = JSONArray().apply { songs.take(200).forEach { put(it.toJson()) } } diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index fbff5a7..9386b28 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -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() } diff --git a/app/src/main/java/com/local/ktv/PushDownload.kt b/app/src/main/java/com/local/ktv/PushDownload.kt index f82d3d4..235ca0d 100644 --- a/app/src/main/java/com/local/ktv/PushDownload.kt +++ b/app/src/main/java/com/local/ktv/PushDownload.kt @@ -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() + /** + * 推送目标的**默认规则**(纯函数,可单测): + * + * 1. 显式配置优先:非空且不是 [DISABLED_VALUES] → 原样使用(**现有用户完全不受影响**); + * 2. 显式关闭(`off`/`none`/`关闭`…)→ 返回空串 = 关闭; + * 3. 留空 → 若 `sourceHosts` 的**第一项是自建源**([SourceHosts.isSelfHost])就用它, + * 否则仍关闭(官方源不能当推送目标)。 + * + * @return 空串 = 关闭;调用方再交给 [endpoint] 判合法性(非 http(s) 一律按关闭处理)。 + */ + @JvmStatic + fun effectiveServerUrl(configured: String?, sourceHosts: List?): 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`。 * diff --git a/app/src/main/java/com/local/ktv/PushDownloadClient.kt b/app/src/main/java/com/local/ktv/PushDownloadClient.kt index be26000..7c9e6fc 100644 --- a/app/src/main/java/com/local/ktv/PushDownloadClient.kt +++ b/app/src/main/java/com/local/ktv/PushDownloadClient.kt @@ -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() } } diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index d4d8076..a6516e2 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -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 diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index 530b669..e81f8cb 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -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 = 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?) { + val list = SourceHosts.parseList(hosts) + synchronized(hostOrder) { + hostOrder.clear() + hostOrder.addAll(list) + } + MiliLog.local("SOURCE-HOSTS 歌曲源已更新(${list.size} 个): ${list.joinToString(" > ")}") + } + + /** 当前运行时源列表(只读快照,供日志/自检)。 */ + fun currentHosts(): List = 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 里的签名/随机串当广告特征(误杀真源)—— 这正是"地址不可信"的隐蔽来源之一。 diff --git a/app/src/main/java/com/local/ktv/SourceHosts.kt b/app/src/main/java/com/local/ktv/SourceHosts.kt new file mode 100644 index 0000000..74c3056 --- /dev/null +++ b/app/src/main/java/com/local/ktv/SourceHosts.kt @@ -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 = parseList(text?.split(SEPARATORS)) + + /** + * 归一化"源入口"列表:去空白/去尾斜杠、只留 `http(s)://…`、按出现顺序去重; + * **空列表 → [DEFAULT]**(设置项被清空时不至于没有源可用)。 + */ + @JvmStatic + fun parseList(items: Collection?): List { + val out = LinkedHashSet() + 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 = parseAllowedList(text?.split(SEPARATORS)) + + /** 归一化整份允许清单;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS]。 */ + @JvmStatic + fun parseAllowedList(items: Collection?): List { + val out = LinkedHashSet() + 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 } + } +} diff --git a/app/src/test/java/com/local/ktv/AppTokenTest.java b/app/src/test/java/com/local/ktv/AppTokenTest.java new file mode 100644 index 0000000..29e0e9f --- /dev/null +++ b/app/src/test/java/com/local/ktv/AppTokenTest.java @@ -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 requested = new java.util.ArrayList(); + 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 sentTokens = new java.util.ArrayList(); + 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}")); + } +} diff --git a/app/src/test/java/com/local/ktv/MediaHostAllowlistTest.java b/app/src/test/java/com/local/ktv/MediaHostAllowlistTest.java new file mode 100644 index 0000000..e173bc1 --- /dev/null +++ b/app/src/test/java/com/local/ktv/MediaHostAllowlistTest.java @@ -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 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 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.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.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 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)); + } +} diff --git a/app/src/test/java/com/local/ktv/SourceHostsTest.java b/app/src/test/java/com/local/ktv/SourceHostsTest.java new file mode 100644 index 0000000..29df2f1 --- /dev/null +++ b/app/src/test/java/com/local/ktv/SourceHostsTest.java @@ -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.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 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 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 officialFirst = new ArrayList(); + 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.emptyList())); + // 配置非法(漏写协议)仍按关闭处理(沿用旧口径) + assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("192.168.1.10:6677", SourceHosts.DEFAULT))); + } +}