diff --git a/AGENTS.md b/AGENTS.md index db3f331..2710078 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,8 +195,10 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.6.0"` / `versionCode 43` - (`1.5.5`/`37` 已发布;`1.5.6` = 更新源加 Gitee、`1.5.7` = 删除「歌曲源临时受限」弹窗、`1.5.8` = 精简远程日志、`1.5.9` = 按 HAR 对齐原版协议、`1.5.10` = 源列表只留 zk.ktv1.top:6677、`1.5.11`(命名越界,已改) / `1.6.0` = whoami 心跳/设备身份/点歌推送/带 query 的地址闸门 + 并入 selfhost-source 支线,均尚未发布。18~21 曾被本机临时诊断包占用) +- `app/build.gradle`:`versionName "1.6.2"` / `versionCode 45` + (`1.5.5`/`37` 已发布;`1.5.6` = 更新源加 Gitee、`1.5.7` = 删除「歌曲源临时受限」弹窗、`1.5.8` = 精简远程日志、`1.5.9` = 按 HAR 对齐原版协议、`1.5.10` = 源列表只留 zk.ktv1.top:6677、`1.5.11`(命名越界,已改) / `1.6.0` = whoami 心跳/设备身份/点歌推送/带 query 的地址闸门 + 并入 selfhost-source 支线、 + `1.6.1`/`44` = App 接入自建源(自建源优先 / App 令牌 / 媒体允许清单 / 推送默认目标)、 + `1.6.2`/`45` = **取址反转成"官方源优先、自建源兜底" + 只有官方源成功才上报**(含 6 条隐患修复与死代码清理),以上 1.5.6~1.6.2 均尚未发布。18~21 曾被本机临时诊断包占用) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **本机 `/tmp` 是 tmpfs,单测必须绕开它**:`/tmp`(5.6G tmpfs)长期被"已删除但仍被打开"的 文件占满,单测写临时文件会抛 `IOException: 超出磁盘配额`(EDQUOT),**看起来像测试失败**。 @@ -331,6 +333,27 @@ $BT/apksigner verify 目标名.apk 裸 socket 会正确使用 `uri.port`(6677)—— 以前只有域名源,改这块要注意别丢端口。 - `AdVideoFilter.AD_HOSTS`(媒体层广告域名黑名单)**不变**:它管的是"返回地址"的 host, 与"取址入口"无关(见上文判定原则)。 +- **⚠️ 取址改为「官方源优先、自建源兜底」+ 只有官方源成功才上报(2026-10-14)**: + `SourceHosts.DEFAULT` = `["http://zk.ktv1.top:6677", "https://mdmuse.kasugano.cn"]` + (**官方源第一、自建源兜底**;与上一版"自建源优先"正好相反)。 + 1. **三处一致**:`ktv_api.js` 的 `CONFIG.HOSTS`/`CONFIG.HOST`、`SongUrlResolver.DEFAULT_HOSTS` + (**已收敛为引用 `SourceHosts.DEFAULT`,不再各写一份字面量**)、`SourceHosts.DEFAULT`; + 由 `SourceOrderConsistencyTest` 与 `tools/check_source_hosts.py` 双保险锁住。 + 2. **上报绑定"官方源成功"**:只有地址来自官方源才 `POST /push_download` 给自建服务器 + (自建源自己给的地址**不上报**)。判断靠取址实现**显式返回的来源标志** + (`SourcedSongUrl.sourceHost` → `SourceHosts.isOfficialSource`), + 在汇合点 `SongApiClient.getSongDownloadUrl` 用 `SongSourceFlow.shouldReportToSelfHost` 一处判定; + **不要**靠 URL 字符串猜、也不要读 `lastHost` 这类可变全局。 + 3. **推送默认目标**:`pushServerUrl` 留空时从 `sourceHosts` 里找**第一个自建源** + (自建源不再排第一,旧写法只看 `first` 会直接把推送关掉)。 + 4. **旧默认值迁移**:1.6.1(已打包、未发布)落盘的"自建源优先"顺序在 `KtvStore.load()` 里 + 迁移成新默认(`SourceHosts.migrateLegacyDefault`,**只有恰好等于旧默认才迁移**, + 用户自定义列表一律不动)。 + 5. **"自建源没有这首歌"(业务码 404)不是风控**:不置 `sawForbidden`、不进冷却、不污染受限统计; + 风控只认 HTTP 403 与 **HTTP 200 + 业务码 403**(`KtvJsBridge.riskForbidden` 与 + `SongUrlResolver.sawForbidden` 同口径)。 + 6. `KTV_BRIDGE_API` 由 14 → **15**(`ktv_api.js` 两处 + `KtvJsBridge` 两处校验; + 本次同时把 JS 的取址结果用 `android.onSongSource(requestId, host)` 带回命中的源)。 - **⚠️ 原版协议对齐(2026-10-08,依据 HAR 抓包实测)**:抓包(Reqable,`e.ac19.cn`)显示原版客户端是**三步**: 1. **`POST /api.php`**:`Content-Type: application/x-www-form-urlencoded`, body `action=get_config&device_id=`,UA @@ -398,7 +421,7 @@ $BT/apksigner verify 目标名.apk 旧实现在 `#fragment` 上会**整条文件名规则失效(漏放广告)**,拿整串搜 `wb66`/`demo` 又会误杀 query 里的签名串("地址不可信"的隐蔽来源)。裸 IP / `pub-*.r2.dev/ss/` 也已收进这道闸门 —— 四条路径不再各判一套。JS 侧同步改(`splitUrl()`), - `KTV_BRIDGE_API` 已升到 **13**;回归用例见 `AdVideoFilterTest`(带 query/fragment 的 + `KTV_BRIDGE_API` 已升到 **15**;回归用例见 `AdVideoFilterTest`(带 query/fragment 的 真源放行、广告仍拒)与服务端 `tests/test_basic.py::TestQueryStringGate`; 3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整), 所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件** @@ -652,8 +675,10 @@ $BT/apksigner verify 目标名.apk 唯一例外是"马上要 `play()` 下一首"的路径(可以不停,避免切歌瞬间静音)。 - **⚠️ 「切歌 → 公放」在老盒子上卡死(2026-09-26,极米 H1S)**:新设备正常、老设备卡死, 排查出**三个叠加原因**,都已修掉,改动这块时必须同时守住: - 1. **不能共用 `io` 线程池**:`io` 只有 4 个线程,且"点歌即下即播"(`streamDownloadAndPlay`) - 这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满,切歌任务一直排队。 + 1. **不能共用 `io` 线程池**:`io` 只有 4 个线程,且"点歌即下即播"(当时是 `streamDownloadAndPlay`, + 该函数已作为死代码在 1.6.2 删除;现在这条链路是 `startDownloadAndPlay` → `downloadSong` + → `SongOkDownloadManager`)这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满, + 切歌任务一直排队。 → 切歌/随机续播改用 `randomPlayExecutor`(专用单线程守护线程)。 2. **不能现扫目录找候选**:候选来自 `localSongInventory()` + `library.scanLocal()`, 代价是"每个文件在头尾各读约 4.3MB 做 TS 校验" + "遍历 `/storage/ 各挂载点` diff --git a/README.en.md b/README.en.md index ece2c10..61e2275 100644 --- a/README.en.md +++ b/README.en.md @@ -73,7 +73,7 @@ Highlights of the reworked data layer: (`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); the code host carries source only, no database files. - **Song source**: `w.w345.my` (`i.php` token → `do.php` playback URL) with fixed device identity and reproduced signing; ad/mismatched resources (e.g. `wb66.cn/.../719.ts`) are blocked and retried. -- **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 7` marker. +- **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 15` marker. ## Building diff --git a/README.md b/README.md index 40e4670..3bf5259 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ - **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖代码托管仓库承载数据库。 - **歌曲源**:接入 `w.w345.my`(`i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。 - **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。 -- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 8` 标记)。 +- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 15` 标记)。 - **应用更新**:以 Gitea Release 标签作为版本号(如 `1.3.0`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。 ## 🔧 编译教程 @@ -132,7 +132,7 @@ adb install -r app/build/outputs/apk/release/app-release.apk - **曲库初始化失败**:检查设备网络能否访问 `mconn.cherryonline.cn`;已在设置页提供「重置数据库」。 - **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。 -- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 8` 标记,否则内置版本优先。 +- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 15` 标记,否则内置版本优先。 - **想改签名/ABI**:编辑 `app/build.gradle` 的 `signingConfigs` 与 `ndk.abiFilters` 后重新编译。 ## 项目结构 diff --git a/app/build.gradle b/app/build.gradle index da477bf..a3beccc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 44 - versionName "1.6.1" + versionCode 45 + versionName "1.6.2" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 @@ -81,6 +81,16 @@ android { buildConfig = true } + testOptions { + unitTests { + // 纯 JVM 单测里 android.jar 是空壳:Log.* 之类默认会抛 "not mocked"。 + // 打开后存根方法返回默认值(Log.w → 0),这样"只记日志、判定另有断言"的 + // 生产代码(如 Eac19WindowGuard / AdVideoFilter.resolve)也能被直接单测。 + // ⚠️ 它只影响"存根返回什么",不改变任何判定逻辑。 + returnDefaultValues = true + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js index 1b8be40..e0da427 100644 --- a/app/src/main/assets/mobile/ktv_api.js +++ b/app/src/main/assets/mobile/ktv_api.js @@ -12,25 +12,29 @@ (function(global) { 'use strict'; - // KTV_BRIDGE_API: 14 (Android 缓存校验标记,请勿删除) + // KTV_BRIDGE_API: 15 (Android 缓存校验标记,请勿删除) // ─── 配置 ────────────────────────────────────────── const CONFIG = { APP_ID: 'd4eeacc6cec3434fbc8c41608a3056a0', APP_KEY: '024210cba40d4385a93e6c2d3249bfb5', SDK_KEY: '19042303a8374f67ae3fe1e25c97936f', - VN: '4.100.5.11121504', - VER: '4', - // 歌曲源入口:**自建源优先、官方源兜底**(2026-10-14)。 - // https://mdmuse.kasugano.cn = 自建歌曲源(App 令牌 / 点歌推送的宿主;媒体站 md.kasugano.cn:4433); - // http://zk.ktv1.top:6677 = 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。 - // ⚠️ 与 Android 侧 SongUrlResolver.DEFAULT_HOSTS(SourceHosts.DEFAULT)**逐项一致**; + // ⚠️ 下面两个值与 App 侧**实际使用**的值一致(`KtvJsBridge` 注入时也是这两个)。 + // 旧文件里写的是 '4.100.5.11121504' / '4',那是**从不生效的死值** —— + // 被桥覆写后就成了历史遗留,直接读本文件会得出错误的协议版本结论。 + // 现在即使不经过桥注入(单独调试本文件)也是同一份协议版本。 + VN: '4.1.3.03281430', + VER: '2.0', + // 歌曲源入口:**官方源优先、自建源兜底**(2026-10-14 反转)。 + // http://zk.ktv1.top:6677 = 官方源(成功率最稳,先试;成功后的地址会上报自建源); + // https://mdmuse.kasugano.cn = 自建歌曲源兜底(官方源取不到时才用它;它自己给的地址**不上报**)。 + // ⚠️ 与 Android 侧 SourceHosts.DEFAULT(SongUrlResolver.DEFAULT_HOSTS 直接引用它)**逐项一致**; // 运行时由 App 设置项注入覆盖(window.KtvApiConfig.HOSTS / HOST)。 HOSTS: [ - 'https://mdmuse.kasugano.cn', 'http://zk.ktv1.top:6677', + 'https://mdmuse.kasugano.cn', ], - HOST: 'https://mdmuse.kasugano.cn', + HOST: 'http://zk.ktv1.top:6677', // 媒体地址允许清单(App 设置项注入覆盖):只放行这些主机的"非域名"限制 —— // 直连内网自建源时媒体地址就是裸 IP(http://192.168.100.232:6677/media/<编号>.ts)。 // ⚠️ 广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss)优先级更高,命中清单也要拒。 @@ -282,6 +286,10 @@ // 歌曲源自动排序: 维护一份"当前优先级"列表(运行时可变)。 // 某源解析失败时把它挪到队尾, 下次点歌优先尝试更可靠的源。 this.sourceOrder = (options.hosts && options.hosts.length ? options.hosts : CONFIG.HOSTS).slice(); + // 最近一次取址**命中的源**(成功时写入;失败/未开始时为空)。 + // 供 Android 侧判定"这条地址是否来自官方源"(只有官方源成功才上报自建服务器)—— + // 由桥在返回地址之前用 `android.onSongSource(requestId, host)` 显式取走。 + this.lastSourceHost = ''; // 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。 this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries)) ? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3; @@ -318,6 +326,7 @@ this.sn = randomHex(16); this.tokens = {}; this.token = ''; + this.lastSourceHost = ''; this._log('Device regenerated: ' + this.mac + '_' + this.sn); }, @@ -355,7 +364,8 @@ // 2. Get Auth Token getToken: async function(host, forceRefresh) { var self = this; - host = host || CONFIG.HOSTS[0]; + // 默认源:CONFIG.HOST(与 HOSTS[0] 同值,由 Android 侧一致注入/校验)优先,其次 HOSTS[0]。 + host = host || CONFIG.HOST || CONFIG.HOSTS[0]; if (!forceRefresh && self.tokens[host]) { self.token = self.tokens[host]; return self.token; @@ -430,6 +440,9 @@ var self = this; var lastDetail = ''; var lastAttemptHost = ''; + // 本次取址命中的源(成功才写入)——由桥在返回地址前用 android.onSongSource 取走, + // 供"只有官方源成功才上报自建服务器"的判定(见 Kotlin SongSourceFlow)。 + self.lastSourceHost = ''; for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) { if (deviceRound > 0) self.regenerateDevice(); // 每一轮使用当时的优先级快照; demote 动态修改 sourceOrder。 @@ -492,6 +505,8 @@ if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) { self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host); self.promoteSource(host); + // 本次命中的源(Android 侧据此决定是否上报自建服务器)。 + self.lastSourceHost = host; hostOk = true; // 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。 reportSongResult(true, host, musicno, 'ok'); @@ -532,7 +547,7 @@ // ─── 导出 ────────────────────────────────────────── global.KtvApi = KtvApi; global.KtvApiConfig = CONFIG; - global.KtvBridgeApiVersion = 14; + global.KtvBridgeApiVersion = 15; 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 f332c21..17613db 100644 --- a/app/src/main/java/com/local/ktv/AdVideoFilter.kt +++ b/app/src/main/java/com/local/ktv/AdVideoFilter.kt @@ -3,14 +3,17 @@ package com.local.ktv import android.util.Log /** - * 屏蔽/替换后端注入的贴片广告视频。 + * 屏蔽后端注入的贴片广告视频。 * * 后端在下发歌曲播放地址时,可能把广告视频(例如 * `http://gz.ac16.vip/ad_files/my_ad_video.ts`)伪装成歌曲地址返回。 - * 这里统一识别并处理: - * - 默认「屏蔽」:广告地址被拦下后,调用方会回落到数据库中的真实歌曲地址 - * (`SongApiClient` 的 DB fallback),从而直接播放歌曲、跳过广告。 - * - 若需要「替换」成自己的视频,把 [replacementUrl] 设置为目标地址即可。 + * 这里统一识别并处理:命中广告特征的地址返回 **null(屏蔽)**, + * 调用方据此回落到数据库中的真实歌曲地址或重新取址 + * (`SongApiClient` 的 DB fallback / `SongOkDownloadManager` 的最终闸门)。 + * + * ⚠️ 2026-10-14 删除了原来那个"替换成自己的视频"的开关 `replacementUrl`: + * 全仓搜索(`grep -rn replacementUrl`)**没有任何写入点**,恒为 null,即"替换"这条分支 + * 从来没有生效过,属于死配置。行为与删除前完全一致(命中广告 → 返回 null)。 */ object AdVideoFilter { private const val TAG = "AdVideoFilter" @@ -45,14 +48,6 @@ object AdVideoFilter { "ac16.vip", ) - /** - * 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底); - * 设置后,命中广告时用该地址替换原广告。 - */ - @Volatile - @JvmField - var replacementUrl: String? = null - /** * **媒体地址允许清单**(设置项 `KtvStore.allowedMediaHosts`,默认 * `md.kasugano.cn` + `192.168.100.232`)。 @@ -203,17 +198,15 @@ object AdVideoFilter { private val R2_HOST = Regex("^pub-[a-z0-9]+\\.r2\\.dev$", RegexOption.IGNORE_CASE) /** - * 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换); - * 非广告地址原样返回。 + * 处理播放地址:广告地址返回 **null(屏蔽)**,非广告地址原样返回。 + * + * ⚠️ 原来的「替换」分支(`replacementUrl`)已于 2026-10-14 删除:全仓没有任何写入点, + * 恒为 null,即"命中广告→返回替换地址"从未生效。现在只保留屏蔽语义。 */ @JvmStatic fun resolve(url: String?): String? { if (!isAdVideo(url)) return url - if (replacementUrl.isNullOrEmpty()) { - Log.w(TAG, "已屏蔽广告视频: $url") - return null - } - Log.i(TAG, "已替换广告视频: $url -> $replacementUrl") - return replacementUrl + Log.w(TAG, "已屏蔽广告视频: $url") + return null } } \ No newline at end of file diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 4ecf0ce..9292e82 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -70,11 +70,130 @@ class KtvJsBridge(context: Context) { } catch (e) {} return 'ok'; })()""" + + /** 热更新脚本的兼容标记(`ktv_api.js` 两处 + 本类两处校验,改协议必须一起改)。 */ + private const val BRIDGE_API_MARKER = "KTV_BRIDGE_API: 15" + + /** + * 已知 CDN 域名后缀(**白名单**,仅用于 [isTrustedDownloadUrl] 的最后一步)。 + * + * ⚠️ **`ac16.vip` 已从这里移除**(2026-10-14):它是**广告域名**,本来就躺在 + * [AdVideoFilter.AD_HOSTS] 黑名单里。旧实现把它同时放进白名单,只因为 `complete()` + * 先调了 `AdVideoFilter.resolve()` 才没出事 —— 那是一个**依赖调用顺序的安全网**。 + * 现在 [isTrustedDownloadUrl] 自己**先判黑名单**,顺序无关。 + */ + private val TRUSTED_HOST_SUFFIXES = listOf( + "ktvsky.com", "origjoy.com", "cherryonline.cn", + "ktvdaren.com", "j-make.cn", + ) + + /** 裸 IPv4 主机(App 只接受域名形式的媒体地址;**命中允许清单的除外**)。 */ + private val IP_HOST_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$") + + /** Cloudflare R2 公开测试桶(配合 path 里的 `/ss/` 判定)。 */ + private val R2_HOST_PATTERN = Regex("^pub-[a-z0-9]+\\.r2\\.dev$") + + /** body 里的**业务码 403**(源站风控的真实形态:HTTP 200 + `{"code":403,…}`)。 */ + private val BODY_CODE_403 = Regex("\"code\"\\s*:\\s*403(?!\\d)") + + /** + * JS 返回地址的可信性判定(**纯函数**,不写日志、不碰 Android API → 可跑纯 JVM 单测)。 + * + * 判定顺序(**黑名单优先**,与 [AdVideoFilter.isTrustedSongUrl] 同口径): + * ``` + * ① 非 http(s) → 拒 + * ② AdVideoFilter.isAdVideo(wb66/demo/ac16.vip/ad_files/my_ad_video)→ 拒(最高优先级) + * ③ pub-*.r2.dev 且 path 含 /ss/ → 拒 + * ④ 命中媒体地址允许清单(自建源裸 IP) → 放行 + * ⑤ 裸 IP → 拒 + * ⑥ 已知 CDN 后缀白名单 → 放行,否则拒 + * ``` + * 顺序无关:即使调用方忘了先跑 [AdVideoFilter.resolve],广告域名也不会被放行。 + */ + @JvmStatic + fun isTrustedDownloadUrl(url: String?): Boolean { + if (url == null) return false + if (!url.startsWith("http://") && !url.startsWith("https://")) return false + val host = AdVideoFilter.hostOf(url) ?: return false + // ② 广告黑名单:优先级最高,命中允许清单也拒(顺序无关)。 + if (AdVideoFilter.isAdVideo(url)) return false + // ③ 测试资源主机(Cloudflare 公开测试桶)。 + if (R2_HOST_PATTERN.matches(host) && AdVideoFilter.pathOf(url).contains("/ss/")) return false + // ④ 媒体地址允许清单(自建源:md.kasugano.cn / 内网裸 IP)。 + if (AdVideoFilter.isAllowedMediaHost(host)) return true + // ⑤ 裸 IP:App 只接受域名(允许清单在上面已放行)。 + if (IP_HOST_PATTERN.matches(host)) return false + // ⑥ "必须是已知 CDN 域名"。 + return TRUSTED_HOST_SUFFIXES.any { host.endsWith(it) } + } + + /** + * 风控判定(HTTP 状态 + **业务码**):源站风控有两种形态 —— + * ① HTTP 403;② HTTP 200 + body `{"code":403,"msg":"请求被拒绝:检测到异常行为"}`。 + * + * JS 路径过去只认前者(`status == 403`),**业务码 403 的风控信号在 JS 路径上会丢**; + * 现在两种都认,与原生 [SongUrlResolver.sawForbidden] 的口径一致。 + * + * ⚠️ 手写判定(不用 `org.json`):单测跑在纯 JVM 上,Android 的 `org.json` 是空壳。 + * 正则带 `(?!\\d)` 负向断言,避免把 `4031` 之类误判成 403。 + */ + @JvmStatic + fun riskForbidden(status: Int, body: String?): Boolean { + if (status == 403) return true + if (body.isNullOrBlank()) return false + return BODY_CODE_403.containsMatchIn(body) + } + + /** + * 「设置页改歌曲源/允许清单后**立即生效**」的注入脚本(纯字符串构造,可单测)。 + * + * 为什么需要它:`getSongUrl` 里的配置注入写在 `if (!window.api)` 内,只在**重建会话**时 + * 执行;而 `window.api.sourceOrder` 是构造时对 `CONFIG.HOSTS` 的快照。 + * 保存设置后如果只改 Kotlin 侧,已建好的 `window.api` 仍按旧顺序选源 —— + * 原生与 JS 会在同一时刻用**不同的源顺序**。这里同时: + * ① 覆写 `KtvApiConfig.HOSTS / HOST / ALLOWED_MEDIA_HOSTS`(下次构造实例用); + * ② 把**已构造实例**的 `sourceOrder` 重置为新的源列表(当前正在用的顺序)。 + */ + @JvmStatic + fun buildApplySourceSettingsJs(hosts: List, allowedMediaHosts: List): String = """ + (function() { + var hosts = ${jsArray(hosts)}; + var allowed = ${jsArray(allowedMediaHosts)}; + try { + if (window.KtvApiConfig) { + window.KtvApiConfig.HOSTS = hosts; + window.KtvApiConfig.HOST = hosts.length ? hosts[0] : ''; + window.KtvApiConfig.ALLOWED_MEDIA_HOSTS = allowed; + } + } catch (e) {} + try { + if (window.api) { + window.api.sourceOrder = hosts.slice(); + } + } catch (e) {} + return 'ok'; + })() + """.trimIndent() + + /** 手写 JSON 数组 / 字符串(`org.json` 在单测里是空壳,见 [DeviceIdentity] 类头注释)。 */ + private fun jsArray(items: List): String = + items.joinToString(",", "[", "]") { jsQuote(it) } + + private fun jsQuote(text: String): String = + "\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" } private val appContext = context.applicationContext private val mainHandler = Handler(Looper.getMainLooper()) - private val callbacks = ConcurrentHashMap Unit>() + + /** + * 取址回调:`(地址, 产出该地址的源)`。第二项是**显式的来源标志**(由 JS 在返回前 + * 通过 `android.onSongSource(requestId, host)` 送达),用于汇合点判定"是否上报自建服务器"。 + */ + private val callbacks = ConcurrentHashMap Unit>() + + /** 每个取址请求命中的源(`requestId -> host`),由 `onSongSource` 写入、`complete` 取出。 */ + private val pendingSourceHosts = ConcurrentHashMap() @Volatile private var webView: WebView? = null @Volatile private var ready = false @@ -287,15 +406,15 @@ class KtvJsBridge(context: Context) { musicNo: String, resolution: String, h265: Boolean, - callback: (String?) -> Unit, + callback: (String?, String?) -> Unit, ) { if (!ready || destroyed) { - callback(null) + callback(null, null) return } val requestId = UUID.randomUUID().toString() callbacks[requestId] = callback - // 源列表 / 媒体地址允许清单**以 App 设置项为准**(自建源优先、官方源兜底): + // 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底): // 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。 val hosts = sourceHostsForJs() val allowedHosts = allowedMediaHostsForJs() @@ -303,10 +422,12 @@ class KtvJsBridge(context: Context) { (function() { var requestId = ${JSONObject.quote(requestId)}; var completed = false; - function finish(url) { + function finish(url, host) { if (completed) return; completed = true; clearTimeout(timer); + // 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。 + try { android.onSongSource(requestId, host || ''); } catch (e) {} android.onUrl(requestId, url || ''); } var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS); @@ -340,10 +461,14 @@ class KtvJsBridge(context: Context) { }); } await window._apiInitPromise; - return window.api.getSongUrl( + var url = await window.api.getSongUrl( ${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"} ); - }).then(finish).catch(function(error) { + // 本次命中的源(KtvApi 成功时记录;失败为空)——随地址一起回传,供"是否上报"判定。 + return {url: url, host: window.api.lastSourceHost || ''}; + }).then(function(result) { + finish(result && result.url, result && result.host); + }).catch(function(error) { android.log('getSongUrl failed: ' + (error && error.message ? error.message : error)); finish(''); }); @@ -355,42 +480,30 @@ class KtvJsBridge(context: Context) { } } - // JS isDemoUrl 已做第一层正则过滤; 这里再做域名白名单兜底, 防止 demo URL 绕过 - private val trustedHostSuffixes = listOf( - "ktvsky.com", "origjoy.com", "cherryonline.cn", "ac16.vip", - "ktvdaren.com", "j-make.cn", - ) - // 已知 demo 域名模式 (Cloudflare R2 测试桶、IP 地址等) - 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 = 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 + /** + * 「设置页改歌曲源/允许清单」后**立即**把新配置注入 JS 会话(原生侧同一时刻已由 + * [SongUrlResolver.applySourceHosts] 生效)。见 [buildApplySourceSettingsJs]。 + */ + fun applySourceSettingsNow() { + val view = webView + if (!ready || destroyed || view == null) return + val script = buildApplySourceSettingsJs(sourceHostsForJs(), allowedMediaHostsForJs()) + mainHandler.post { + runCatching { view.evaluateJavascript(script, null) } + .onFailure { Log.w(TAG, "apply source settings failed", it) } } - // 媒体地址允许清单(自建源: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 - } - val trusted = trustedHostSuffixes.any { host.endsWith(it) } - if (!trusted) Log.w(TAG, "Untrusted URL host: $host") - return trusted } private fun complete(requestId: String, url: String?) { - // 先屏蔽/替换广告视频,再做域名白名单校验。 - callbacks.remove(requestId)?.invoke(AdVideoFilter.resolve(url)?.takeIf { isTrustedDownloadUrl(it) }) + // 本次地址来自哪个源:**显式标志**优先,取不到才退回"最近一次 JS 上报的源"。 + val sourceHost = pendingSourceHosts.remove(requestId).orEmpty().ifEmpty { lastSongSourceHost } + // 先屏蔽广告视频(AdVideoFilter 黑名单),再过 CDN 白名单/允许清单(本类自带黑名单优先判定)。 + val resolved = AdVideoFilter.resolve(url) + val trusted = resolved?.takeIf { isTrustedDownloadUrl(it) } + if (resolved != null && trusted == null) { + Log.w(TAG, "Untrusted URL rejected: host=${AdVideoFilter.hostOf(resolved)}") + } + callbacks.remove(requestId)?.invoke(trusted, sourceHost) } private fun readAssetScript(): String = @@ -400,7 +513,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: 14") + it.contains("KtvApi") && it.contains(BRIDGE_API_MARKER) } }.getOrDefault(false) @@ -426,7 +539,7 @@ class KtvJsBridge(context: Context) { val script = String(bytes, StandardCharsets.UTF_8) require( script.length > 1000 && script.contains("KtvApi") && - script.contains("KTV_BRIDGE_API: 14"), + script.contains(BRIDGE_API_MARKER), ) { "Invalid or incompatible JS file" } tempFile.writeText(script, Charsets.UTF_8) if (!tempFile.renameTo(cacheFile)) { @@ -503,6 +616,18 @@ class KtvJsBridge(context: Context) { complete(requestId, url) } + /** + * JS 在返回地址**之前**回传"本次命中的源"(`requestId -> host`)。 + * + * 这是"是否上报自建服务器"的**显式来源标志**(只有官方源成功才上报), + * 与 `onUrl` 分开传是为了兼容旧脚本:老脚本不会调它, + * [complete] 会退回 `lastSongSourceHost`(由 `reportSongResult` 维护)。 + */ + @JavascriptInterface + fun onSongSource(requestId: String, host: String) { + if (host.isNotEmpty()) pendingSourceHosts[requestId] = host + } + @JavascriptInterface fun rsaEncryptPkcs1(publicKeyBase64: String, plaintext: String): String = runCatching { @@ -562,17 +687,23 @@ class KtvJsBridge(context: Context) { // 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection // 补发第二次(限流时补发只会双倍放大请求量,加速被源限流)。 val raw = rawRequest(method, url, body, headersJson, timeoutMs) - val status = runCatching { JSONObject(raw).optInt("status") }.getOrDefault(0) + val envelope = runCatching { JSONObject(raw) }.getOrNull() + val status = envelope?.optInt("status") ?: 0 if (status > 0) { + val responseBody = envelope?.optString("body").orEmpty() // 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。 if (status == 403 || status >= 500) { MiliLog.localError("HTTP-$status $method $url") } - // 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为", - // 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。 - // ⚠️ 取 token 的 `/i.php` 同样会 403(2026-10 实测主源 w.w345.my/i.php 就是 403)—— - // 以前只认 `/music/do.php`,全都 403 时用户看不到任何提示。 - if (status == 403 && (url.contains("/i.php") || url.contains("/music/do.php"))) { + // 歌曲源试用窗口/风控到期有两种形态: + // ① HTTP 403;② **HTTP 200 + 业务码 403**(`{"code":403,"msg":"请求被拒绝:检测到异常行为"}`, + // 2026-10-08 实测)。旧实现只认 ①,JS 路径上的风控信号就丢了 + // (远程日志里只能看到 `do.php code=403`);现在两种都认, + // 与原生 `SongUrlResolver.sawForbidden` 口径一致。 + // ⚠️ 取 token 的 `/i.php` 同样会 403 —— 以前只认 `/music/do.php`。 + if (riskForbidden(status, responseBody) && + (url.contains("/i.php") || url.contains("/music/do.php")) + ) { Eac19WindowGuard.noteRiskBlocked() } return raw diff --git a/app/src/main/java/com/local/ktv/KtvStore.kt b/app/src/main/java/com/local/ktv/KtvStore.kt index b6de24a..a57344a 100644 --- a/app/src/main/java/com/local/ktv/KtvStore.kt +++ b/app/src/main/java/com/local/ktv/KtvStore.kt @@ -68,21 +68,27 @@ class KtvStore { * 「App 点歌推送」:把点歌取到的下载地址同步推给自建服务器(`POST <地址>/push_download`, * 见 [PushDownload] / [PushDownloadClient] / [SongApiClient.getSongDownloadUrl])。 * - * * **留空 = 自动**:若 [sourceHosts] 的第一项是自建源,就用它当推送目标 - * (见 [PushDownload.effectiveServerUrl],2026-10-14 自建源接入后成为默认行为); + * * **留空 = 自动**:从 [sourceHosts] 里找**第一个自建源**用它当推送目标 + * (见 [PushDownload.effectiveServerUrl];2026-10-14 起源列表是"官方源优先"、自建源不再排第一, + * 所以不再看 `first`); * * **填 `off` / `none` / `关闭` = 显式关闭**(连请求都不发); * * 填其它 `http(s)://…` = 显式覆盖(现有用户完全不受影响)。 + * + * ⚠️ 只有"地址来自官方源"的取址成功才会真的推送(自建源自己给出的地址不上报)。 */ @JvmField var pushServerUrl = "" /** 推送鉴权串:必须与服务端的 `push_token` 一致(默认 [DEFAULT_PUSH_TOKEN])。 */ @JvmField var pushToken = DEFAULT_PUSH_TOKEN /** - * **歌曲源入口列表**(自建源优先、官方源兜底,默认 = [SourceHosts.DEFAULT])。 + * **歌曲源入口列表**(官方源优先、自建源兜底,默认 = [SourceHosts.DEFAULT])。 * * App 侧([SongUrlResolver])与 JS 侧(`KtvJsBridge` 注入 `CONFIG.HOSTS`)**都以它为准**; * 设置页按"一行一个"编辑,解析/归一化见 [SourceHosts.parse] / [SourceHosts.parseList] * —— 清空即回落默认列表,绝不会变成"没有源可用"。 + * + * ⚠️ 1.6.1(已打包、未发布)落盘的"自建源优先"旧默认值会在 [load] 里迁移成新默认 + * (见 [SourceHosts.migrateLegacyDefault]);用户自定义的列表一律不动。 */ @JvmField val sourceHosts: MutableList = SourceHosts.DEFAULT.toMutableList() @@ -153,8 +159,14 @@ class KtvStore { pushServerUrl = root.optString("pushServerUrl", "") pushToken = root.optString("pushToken", DEFAULT_PUSH_TOKEN).ifBlank { DEFAULT_PUSH_TOKEN } // 源列表 / 媒体地址允许清单:空(旧配置没有这两个键)→ 回落默认值。 + // ⚠️ 源列表额外做一次"旧默认值 → 新默认值"迁移(1.6.1 落盘的是自建源优先; + // 用户自定义的列表不受影响),见 SourceHosts.migrateLegacyDefault。 sourceHosts.clear() - sourceHosts.addAll(SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts")))) + sourceHosts.addAll( + SourceHosts.migrateLegacyDefault( + SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts"))), + ), + ) allowedMediaHosts.clear() allowedMediaHosts.addAll(SourceHosts.parseAllowedList(readStringList(root.optJSONArray("allowedMediaHosts")))) readSongs(root.optJSONArray("orderQueue"), orderQueue) diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 9386b28..6ce676d 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -140,9 +140,10 @@ class MainActivity : AppCompatActivity() { /** * 「切歌 → 公放」专用单线程执行器。 * - * ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`streamDownloadAndPlay`)这类 - * 几十秒的长任务也在里面。极米 H1S 上并发点几首歌就能把池占满,切歌任务于是一直排队 —— - * 旧歌播完后彻底没声、按什么都没反应(用户报的"老设备卡死",新设备因下载快而看不出来)。 + * ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`startDownloadAndPlay` → + * `downloadSong` → `SongOkDownloadManager`)这类几十秒的长任务也在里面。极米 H1S 上并发点 + * 几首歌就能把池占满,切歌任务于是一直排队 —— 旧歌播完后彻底没声、按什么都没反应 + * (用户报的"老设备卡死",新设备因下载快而看不出来)。 */ private val randomPlayExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "ktv-random-play").apply { isDaemon = true } @@ -482,7 +483,7 @@ class MainActivity : AppCompatActivity() { Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}") } store.load() - // 歌曲源列表 / 媒体地址允许清单(自建源优先、官方源兜底):一处装载, + // 歌曲源列表 / 媒体地址允许清单(**官方源优先、自建源兜底**):一处装载, // 原生取址(SongUrlResolver)、地址闸门(AdVideoFilter)与 JS 取址(KtvJsBridge 注入 // CONFIG.HOSTS / CONFIG.ALLOWED_MEDIA_HOSTS)三处都读它。 applySourceSettings() @@ -4981,6 +4982,14 @@ class MainActivity : AppCompatActivity() { if (currentPage == "已点" || currentPage == "首页") renderQueue() } + /** + * 备用/历史下载入口(**第五条地址消费点**):直接用曲库条目自带的 `downloadUrl`/`videoUrl`。 + * + * ⚠️ 它**不取址**,所以地址必须自己过闸门([AdVideoFilter.isTrustedSongUrl],与四条取址路径 + * 同一口径)。历史实现直接下载、完全不过闸门 —— 一旦曲库条目(`Song.remote()` 会填 + * `downloadUrl`)里带的是广告地址,就会绕过全部防线。不可信一律按「源失效」处理: + * 记日志 + 走 [Eac19WindowGuard.noteAllSourcesFailed](「远程源已全部失效,等待新版本修复」)。 + */ private fun download(song: Song) { val mainUrl = if (song.downloadUrl == null || song.downloadUrl!!.trim { it <= ' ' } .isEmpty()) song.videoUrl else song.downloadUrl @@ -4988,6 +4997,15 @@ class MainActivity : AppCompatActivity() { toast("该条目没有下载地址") return } + // 地址闸门:不可信(广告/错号/非 http(s)/裸 IP…)按"源失效"处理,绝不静默播放。 + if (!AdVideoFilter.isTrustedSongUrl(mainUrl, extractTid(song))) { + MiliLog.error( + "点歌编号=${extractTid(song)} 标题=${song.title} 状态=失败 " + + "详情=地址不可信(备用下载入口) url=$mainUrl", + ) + Eac19WindowGuard.noteAllSourcesFailed() + return + } val task = DownloadTask(song) downloads.add(task) busy(true, "正在下载:" + song.title) @@ -5225,102 +5243,6 @@ class MainActivity : AppCompatActivity() { } } - /** - * 完整下载后播放。VideoView不支持流式TS,必须下载完再播。 - * 使用256KB大缓冲区加速下载,实时显示进度。 - */ - private fun streamDownloadAndPlay(song: Song) { - io.execute(Runnable { - var tmpFile: File? = null - try { - var url = song.downloadUrl - if (url == null || url.isEmpty()) { - // 4 参重载 = 顺带把歌名交给「App 点歌推送」(推送挂在取址汇合点里,见 SongApiClient) - url = getSongDownloadUrl(extractTid(song).orEmpty(), "720", false, song.title.orEmpty()) - if (url != null) song.downloadUrl = url - } - if (url == null || url.isEmpty()) { - MiliLog.error( - "点歌编号=${song?.let(::extractTid)} 标题=${song?.title} 请求源=" + - "${SongUrlResolver.lastHost.ifEmpty { "unknown" }} 状态=失败 详情=无法获取播放地址", - ) - main.post(Runnable { - hideDownloadProgress() - toast("无法获取播放地址") - }) - return@Runnable - } - - val finalFile = getLocalFile(song) - finalFile.getParentFile().mkdirs() - tmpFile = File(finalFile.getParent(), finalFile.getName() + ".tmp") - - val conn = URL(url).openConnection() as HttpURLConnection - conn.setConnectTimeout(10000) - conn.setReadTimeout(30000) - conn.setRequestProperty("User-Agent", "ThunderSDK/4.1.3") - conn.connect() - - val totalSize = conn.getContentLength().toLong() - val `in`: InputStream = BufferedInputStream(conn.getInputStream(), 262144) - val out = FileOutputStream(tmpFile) - - val buf = ByteArray(262144) // 256KB buffer for speed - var downloaded: kotlin.Long = 0 - var lastPct = 0 - var lastTime = System.currentTimeMillis() - var lastBytes: kotlin.Long = 0 - - while (true) { - val read = `in`.read(buf) - if (read < 0) break - out.write(buf, 0, read) - downloaded += read.toLong() - - // 每秒更新一次进度和速度 - val now = System.currentTimeMillis() - if (now - lastTime > 1000) { - val pct = if (totalSize > 0) (downloaded * 100 / totalSize).toInt() else 0 - val speed = downloaded - lastBytes - lastPct = pct - lastTime = now - lastBytes = downloaded - val p = pct - val speedStr = formatSize(speed) + "/s" - main.post(Runnable { - updateDownloadProgress(song, p) - if (textDownloadSpeed != null) textDownloadSpeed!!.setText(speedStr) - }) - } - } - out.close() - `in`.close() - conn.disconnect() - - // 移到正式位置 - tmpFile.renameTo(finalFile) - song.path = finalFile.getAbsolutePath() - randomCandidates[stableId(song)] = song - - main.post(Runnable { - hideDownloadProgress() - if (currentSong != null && currentSong!!.equals(song)) { - playLocalFile(song) - } - toast(song.title + " 就绪") - }) - } catch (e: Exception) { - Log.e(TAG, "download error: " + e.message) - if (tmpFile != null) tmpFile.delete() - MiliLog.error("DL-ERR id=${song?.let(::extractTid)} title=${song?.title} ${e.message}") - main.post(Runnable { - hideDownloadProgress() - toast("下载失败: " + e.message) - }) - } - }) - } - /** * 下载歌曲并在完成后自动播放 (旧版,备用)。 */ @@ -8869,7 +8791,7 @@ class MainActivity : AppCompatActivity() { "推送下载", PushDownload.endpoint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)) ?.let { "已开启:点歌地址推送至 $it" } - ?: "未开启(留空 = 自动用首个自建源;填 off 关闭)", + ?: "未开启(留空 = 自动用歌曲源里的自建源;填 off 关闭)", "设置", ) { showPushDownloadSettings() }, SettingsEntry( @@ -9371,9 +9293,12 @@ class MainActivity : AppCompatActivity() { /** * 「推送下载」设置:把点歌取到的下载地址同步推给自建服务器(服务端 `POST /push_download`)。 * - * ⚠️ **留空 = 自动**:首个源是自建源时直接用它当推送目标(见 [PushDownload.effectiveServerUrl]); - * 填 `off` / `none` / `关闭` 则关闭该功能(点歌时连请求都不发);填其它地址 = 显式覆盖。 + * ⚠️ **留空 = 自动**:从歌曲源列表里找**自建源**用它当推送目标(见 [PushDownload.effectiveServerUrl]; + * 2026-10-14 起源列表是"官方源优先",自建源不再排第一);填 `off` / `none` / `关闭` 则关闭该功能 + * (点歌时连请求都不发);填其它地址 = 显式覆盖。 * 改完立刻生效(推送时现读 [store]),不需要重启。 + * + * ⚠️ 只有"地址来自官方源"的取址成功才会真的推送(自建源取到的地址不上报)。 */ private fun showPushDownloadSettings() { val box = LinearLayout(this) @@ -9383,12 +9308,12 @@ class MainActivity : AppCompatActivity() { urlInput.setSingleLine(true) urlInput.setText(store.pushServerUrl) urlInput.setHint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts) - .ifEmpty { SourceHosts.DEFAULT.first() } + "(留空 = 自动)") + .ifEmpty { "歌曲源里没有自建源" } + "(留空 = 自动)") val tokenInput = EditText(this) tokenInput.setSingleLine(true) tokenInput.setText(store.pushToken) tokenInput.setHint(KtvStore.DEFAULT_PUSH_TOKEN) - box.addView(label("自建服务器地址(留空 = 自动用首个自建源;填 off 关闭)", 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))) @@ -9410,19 +9335,26 @@ class MainActivity : AppCompatActivity() { /** * 把「歌曲源 / 媒体地址允许清单」设置应用到两份取址实现与地址闸门。 * - * 一处装载、三处生效:原生取址([SongUrlResolver.applySourceHosts])、地址闸门 - * ([AdVideoFilter.setAllowedMediaHosts])、JS 取址([KtvJsBridge] 每次取址读同一份设置注入 - * `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`)。启动与设置页保存后各调一次即可。 + * **四处生效**:原生取址([SongUrlResolver.applySourceHosts])、地址闸门 + * ([AdVideoFilter.setAllowedMediaHosts])、JS 取址的下一次构造 + * ([KtvJsBridge] 每次取址读同一份设置注入 `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`), + * 以及**当前已构造的 JS 会话**([SongApiClient.applySourceSettings] → 重新注入 + * `KtvApiConfig.*` 并重置 `window.api.sourceOrder`)。 + * + * ⚠️ 最后一项是隐患 1 的修复:只写下面前两行时,原生侧**立刻**换源, + * 而 JS 主路径仍按构造时的旧 `sourceOrder` 选源 —— 两条路径会在同一时刻用不同的源顺序。 */ private fun applySourceSettings() { SongUrlResolver.applySourceHosts(store.sourceHosts) AdVideoFilter.setAllowedMediaHosts(store.allowedMediaHosts) + SongApiClient.applySourceSettings() } /** - * 「歌曲源」设置:**自建源优先、官方源兜底**,一行一个。 + * 「歌曲源」设置:**官方源优先、自建源兜底**,一行一个。 * - * 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`)。 + * 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`), + * 保存后**立即**对两条路径生效(见 [applySourceSettings])。 */ private fun showSourceHostsDialog() { val input = EditText(this).apply { @@ -9441,9 +9373,9 @@ class MainActivity : AppCompatActivity() { label("只认 http(s):// 开头;留空 = 回落默认:\n" + SourceHosts.DEFAULT.joinToString("\n"), 13, Color.LTGRAY), ) AlertDialog.Builder(this) - .setTitle("歌曲源(自建源优先、官方源兜底)") + .setTitle("歌曲源(官方源优先、自建源兜底)") .setView(box) - .setMessage("第一个源同时是「推送下载」的默认目标。清空即恢复默认列表。") + .setMessage("官方源成功后会把地址上报自建源(POST /push_download);自建源自己取到的地址不上报。清空即恢复默认列表。") .setNegativeButton("取消", null) .setPositiveButton("保存") { _: DialogInterface?, _: Int -> store.applySourceHostsText(input.text.toString()) diff --git a/app/src/main/java/com/local/ktv/PushDownload.kt b/app/src/main/java/com/local/ktv/PushDownload.kt index 235ca0d..26f8f79 100644 --- a/app/src/main/java/com/local/ktv/PushDownload.kt +++ b/app/src/main/java/com/local/ktv/PushDownload.kt @@ -16,9 +16,12 @@ import java.util.LinkedHashMap * 端点拼接 / body 拼接 / 5 分钟去重 / "配置为空就绝不调用发送器" —— 全是纯函数; * 真正发请求(后台线程 + 8 秒超时 + HttpURLConnection)在 [PushDownloadClient]。 * - * ⚠️ **默认目标**(2026-10-14 自建源接入后):`pushServerUrl` **留空 = 自动** —— 首源是自建源时 - * 就用它当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。 + * ⚠️ **默认目标**(2026-10-14):`pushServerUrl` **留空 = 自动** —— 从歌曲源列表里找**自建源** + * 当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。 * 关闭时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。 + * + * ⚠️ 触发条件(2026-10-14 起):只有"地址来自**官方源**"的取址成功才推送 + * (见 [SourcedSongUrl.fromOfficialSource] / `SongApiClient.getSongDownloadUrl`)。 */ object PushDownload { /** 与服务端 `push_token` 的默认值保持一致。 */ @@ -36,7 +39,7 @@ object PushDownload { /** * 显式"关闭推送"的取值(见 [effectiveServerUrl])。 * - * 为什么需要它:`pushServerUrl` **留空 = 自动**(首源是自建源时就用它当推送目标), + * 为什么需要它:`pushServerUrl` **留空 = 自动**(从歌曲源列表里找自建源当推送目标), * 所以"清空关闭"必须有一个明确写法,否则用户没法把自动默认关掉。 */ private val DISABLED_VALUES = setOf("off", "none", "no", "0", "false", "关闭", "禁用") @@ -49,8 +52,12 @@ object PushDownload { * * 1. 显式配置优先:非空且不是 [DISABLED_VALUES] → 原样使用(**现有用户完全不受影响**); * 2. 显式关闭(`off`/`none`/`关闭`…)→ 返回空串 = 关闭; - * 3. 留空 → 若 `sourceHosts` 的**第一项是自建源**([SourceHosts.isSelfHost])就用它, - * 否则仍关闭(官方源不能当推送目标)。 + * 3. 留空 → 取 `sourceHosts` 里**第一个自建源**([SourceHosts.isSelfHost])就用它, + * 一个自建源都没有 → 关闭(官方源不能当推送目标)。 + * + * ⚠️ 2026-10-14 起源列表改为「官方源优先、自建源兜底」,自建源**不一定排在第一位** —— + * 所以这里改为"**按列表找第一个自建源**"(旧写法只看 `first`,新默认顺序下会直接把推送关掉, + * 而"官方源成功就上报"正是这一版的核心需求)。 * * @return 空串 = 关闭;调用方再交给 [endpoint] 判合法性(非 http(s) 一律按关闭处理)。 */ @@ -60,8 +67,8 @@ object PushDownload { 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 "" + val self = sourceHosts?.firstOrNull { SourceHosts.isSelfHost(it) }?.trim().orEmpty() + return if (SourceHosts.isSelfHost(self)) self else "" } /** diff --git a/app/src/main/java/com/local/ktv/PushDownloadClient.kt b/app/src/main/java/com/local/ktv/PushDownloadClient.kt index 7c9e6fc..1e33d8c 100644 --- a/app/src/main/java/com/local/ktv/PushDownloadClient.kt +++ b/app/src/main/java/com/local/ktv/PushDownloadClient.kt @@ -6,11 +6,12 @@ import java.net.URL /** * 「App 点歌推送」的 **Android 侧胶水**:读设置 → 后台线程 → 8 秒超时 → fire-and-forget。 * - * ## 切入点 + * ## 切入点与触发条件 * 由 [SongApiClient.getSongDownloadUrl] 在**拿到下载地址之后**调用(那是 JS / 原生 / DB 回退 - * 四条取址路径唯一的汇合点)。本类的每一步都**不能影响点歌**: - * * 目标默认:`pushServerUrl` 留空时用**首个自建源**(见 [PushDownload.effectiveServerUrl]); - * 显式 `off`/`none`/`关闭` 或首源不是自建源时 [PushDownload.endpoint] 为 null + * 四条取址路径唯一的汇合点),并且**只有地址来自官方源时才调用** + * (见 [SongSourceFlow.shouldReportToSelfHost])。本类的每一步都**不能影响点歌**: + * * 目标默认:`pushServerUrl` 留空时用歌曲源列表里的**自建源**(见 [PushDownload.effectiveServerUrl]); + * 显式 `off`/`none`/`关闭` 或列表里没有自建源时 [PushDownload.endpoint] 为 null * → **连线程都不起、绝不发请求**; * * 5 分钟内存去重([PushDownload.shouldSkip]):同一首歌反复点/重复取址只推一次; * * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示); @@ -47,7 +48,7 @@ object PushDownloadClient { private fun pushNow(tid: Long, url: String, name: String) { val store = KtvStore() store.load() - // 目标:显式配置优先;留空且首源是自建源 → 默认用它(2026-10-14 起的行为)。 + // 目标:显式配置优先;留空 → 用歌曲源列表里的自建源(2026-10-14 起的行为)。 val serverUrl = PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts) if (PushDownload.endpoint(serverUrl) == null) return // 关闭:不发请求(连 POST 都不构造) val host = SourceHosts.hostOf(serverUrl) diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index a6516e2..ba26f98 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -63,6 +63,12 @@ object SongApiClient { * (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了 * 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。 * + * ⚠️ **只有"地址来自官方源"才推送**(2026-10-14 新流程):官方源成功 → 把该下载地址 + * 上报自建服务器(`POST /push_download`,让它留存并入库这首);地址来自**自建源**时不推 + * (服务器本来就有这首),DB 兜底同样不推。判定用的是取址实现**显式返回**的来源标志 + * ([SourcedSongUrl.sourceHost],见 [SongSourceFlow.shouldReportToSelfHost]), + * **不靠 URL 字符串猜、也不读可变全局**。 + * * 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、 * 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。 * @@ -70,16 +76,25 @@ object SongApiClient { */ @JvmStatic fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? { - val url = resolveSongDownloadUrl(musicno, resolution, h265) - if (!url.isNullOrEmpty() && musicno.isNotBlank()) { + val resolved = resolveSongDownloadUrl(musicno, resolution, h265) + val url = resolved?.url + if (!url.isNullOrEmpty() && musicno.isNotBlank() && + SongSourceFlow.shouldReportToSelfHost(resolved) + ) { runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) } .onFailure { Log.w(TAG, "push download skipped", it) } } return url } - /** 真正的取址实现(推送不参与其逻辑,见上面的入口)。 */ - private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? { + /** + * 真正的取址实现(推送不参与其逻辑,见上面的入口)。 + * + * 返回 [SourcedSongUrl](= 地址 + **产出它的源**)而不是裸字符串:汇合点据此判定 + * "是否上报自建服务器"(见 [getSongDownloadUrl])。回落链 + * JS 主路径 → 原生兜底 → DB 回退由 [SongSourceFlow.firstAvailable] 表达。 + */ + private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? { // 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源 if (musicno.isBlank()) return null // 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。 @@ -90,11 +105,18 @@ object SongApiClient { Log.w(TAG, "Synchronous request on main thread; using DB fallback only") return fetchFromDb(musicno) } - val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) + val bridge = bridge + ?: return SongSourceFlow.firstAvailable( + { resolveNatively(musicno, resolution, h265) }, + { fetchFromDb(musicno) }, + ) // 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。 // (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。) if (bridge.isUnavailable()) { - return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) + return SongSourceFlow.firstAvailable( + { resolveNatively(musicno, resolution, h265) }, + { fetchFromDb(musicno) }, + ) } // 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备 // (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。 @@ -112,11 +134,18 @@ object SongApiClient { // 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。 Log.w(TAG, "Bridge not ready after wait, native resolver") bridge.markUnavailable("15s 未就绪") - return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) + return SongSourceFlow.firstAvailable( + { resolveNatively(musicno, resolution, h265) }, + { fetchFromDb(musicno) }, + ) } - val result = AtomicReference() + val result = AtomicReference() val latch = CountDownLatch(1) - bridge.getSongUrl(musicno, resolution, h265) { url -> result.set(url); latch.countDown() } + bridge.getSongUrl(musicno, resolution, h265) { url, sourceHost -> + // JS 同时回传"本次命中的源"(第二参)→ 构造**显式来源标志**。 + result.set(url?.takeIf { it.isNotEmpty() }?.let { SourcedSongUrl(it, sourceHost.orEmpty()) }) + latch.countDown() + } // JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。 try { latch.await(185, TimeUnit.SECONDS) @@ -125,37 +154,60 @@ object SongApiClient { } // 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会, // 避免 JS 侧偶发失败直接把第一首歌判死。 - val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno) - if (url != null) { + val resolved = SongSourceFlow.firstAvailable( + { result.get() }, + { resolveNatively(musicno, resolution, h265) }, + { fetchFromDb(musicno) }, + ) + if (resolved != null) { // 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。 Eac19WindowGuard.noteUrlOk() } - return url + return resolved } /** * 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。 * 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。 */ - private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? = - runCatching { SongUrlResolver.resolve(musicno, resolution, h265) } + private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? = + runCatching { SongUrlResolver.resolveSourced(musicno, resolution, h265) } .onFailure { Log.w(TAG, "native resolver failed", it) } .getOrNull() - /** DB cloud_url fallback — 必须通过 demo 检测才能使用 */ - private fun fetchFromDb(musicno: String): String? { try { + /** + * DB `cloud_url` 回落:必须通过 demo 检测与地址闸门才能使用。 + * + * 返回的 [SourcedSongUrl.sourceHost] 是**空串** —— DB 里的地址既不是官方源也不是自建源 + * 现取的,因此 [SourcedSongUrl.fromOfficialSource] 为 false → **不上报** + * (符合"只有官方源成功才上报")。 + * + * ⚠️ 这里**不再静默吞异常**(隐患 5):DB 打不开 / 查询抛异常与"DB 里没有 cloud_url" + * 以前外观完全一样,现场根本分不出是"曲库坏了"还是"这首歌没有云地址"。 + * 现在前者各记一条 `MiliLog.localWarn`(含编号与异常信息)。 + */ + private fun fetchFromDb(musicno: String): SourcedSongUrl? { + return try { val db = MuseDatabase() - if (db.open()) { - val url = db.getCloudUrl(musicno) - db.close() - // DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验 - // (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。 - if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) { - return url - } + if (!db.open()) { + MiliLog.localWarn("SONG-DB 曲库未就绪,跳过 cloud_url 回落 编号=$musicno") + return null } - } catch (_: Exception) {} - return null + val url = db.getCloudUrl(musicno) + db.close() + // DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验 + // (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。 + if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) { + SourcedSongUrl(url, "") + } else { + null + } + } catch (e: Exception) { + MiliLog.localWarn( + "SONG-DB 查询 cloud_url 异常 编号=$musicno ${e.javaClass.simpleName}: ${e.message}", + ) + null + } } /** @@ -195,6 +247,19 @@ object SongApiClient { bridge?.resetApiSession() } + /** + * 设置页改了「歌曲源 / 媒体地址允许清单」后**立即**把新配置注入 JS 会话(隐患 1)。 + * + * 原生侧由 [SongUrlResolver.applySourceHosts] 立刻生效;JS 侧过去只在下一次"重建会话" + * (`window.api == null`)时才拿到新列表,已构造的 `window.api.sourceOrder` 仍是旧顺序 —— + * 两条取址路径会在同一时刻用**不同的源顺序**。现在保存设置后直接重新注入 + * (`KtvApiConfig.*` + `window.api.sourceOrder`),两端立刻一致。 + */ + @JvmStatic + fun applySourceSettings() { + bridge?.applySourceSettingsNow() + } + /** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */ @JvmStatic fun lastSongSourceHost(): String = diff --git a/app/src/main/java/com/local/ktv/SongSourceFlow.kt b/app/src/main/java/com/local/ktv/SongSourceFlow.kt new file mode 100644 index 0000000..f9c8ced --- /dev/null +++ b/app/src/main/java/com/local/ktv/SongSourceFlow.kt @@ -0,0 +1,79 @@ +package com.local.ktv + +/** + * 「官方源优先、自建源兜底」流程的**纯逻辑**(不碰 Android API,可跑纯 JVM 单测)。 + * + * ## 这一版改了什么 + * 取址顺序**反转**为「① 官方源 → ② 自建源兜底」: + * ``` + * 点歌 → ① 官方源 zk.ktv1.top:6677 取址(/i.php → /music/do.php,多源按需重试) + * ├─ 成功 → 用官方地址播放/下载,并**把该下载地址上报自建源**(POST /push_download) + * └─ 失败 → ② 自建源取址(GET /token → /music/do.php) + * ├─ 有且过地址闸门 → 用自建地址,**不上报** + * └─ 无/失败 → ③ 弹「远程源已全部失效,等待新版本修复」(Eac19WindowGuard) + * ``` + * + * ## 为什么把"来源"做成显式的数据而不是环境状态 + * "是否上报"取决于**这次地址是哪条路/哪个源给的**。历史写法靠 `lastHost` 之类的可变全局、 + * 甚至靠 URL 字符串猜,在多线程下载下会串味、也读不出"到底该不该推"。 + * 现在由各取址实现**显式返回** [SourcedSongUrl],在汇合点用 + * [shouldReportToSelfHost] 一处判定(见 `SongApiClient.getSongDownloadUrl`)。 + */ +data class SourcedSongUrl( + /** 本次取到的媒体地址(已过各自那一道地址闸门)。 */ + val url: String, + /** + * 产出该地址的**源**:JS/原生取址时是命中的源入口(如 `http://zk.ktv1.top:6677`), + * DB 回落时是空串(既不是官方源也不是自建源 → 不上报)。 + */ + val sourceHost: String, +) { + /** + * 本次地址是否来自**官方源** —— 只有它为 true 才触发「上报自建服务器」。 + * 判定收敛在 [SourceHosts.isOfficialSource](按主机名,不看 URL 字符串)。 + */ + val fromOfficialSource: Boolean get() = SourceHosts.isOfficialSource(sourceHost) +} + +/** 取址回落链里的一"级"(生产 = JS 桥 / 原生兜底 / DB 回退;单测 = 假实现)。 */ +fun interface SongSourceStage { + fun resolve(): SourcedSongUrl? +} + +object SongSourceFlow { + + /** + * 回落链:**按顺序**执行各级取址,返回第一条非空结果(连同它的来源标志)。 + * + * 顺序即 `SongApiClient` 里的 JS 主路径 → 原生兜底 → DB 回退;各级为懒执行, + * 前一级拿到地址后**不会**再跑后面的(DB 不会白查、原生不会白扫源)。 + */ + @JvmStatic + fun firstAvailable(vararg stages: SongSourceStage): SourcedSongUrl? { + for (stage in stages) { + val resolved = stage.resolve() ?: continue + if (resolved.url.isNotEmpty()) return resolved + } + return null + } + + /** + * 汇合点判定:**只有"地址来自官方源"才上报自建服务器**(`POST /push_download`)。 + * + * * 官方源成功 → true(让自建服务器留存该曲目); + * * 自建源给出的地址 → false(服务器本来就有,再推是白推); + * * DB 兜底 / 空结果 → false。 + */ + @JvmStatic + fun shouldReportToSelfHost(resolved: SourcedSongUrl?): Boolean = + resolved != null && resolved.url.isNotEmpty() && resolved.fromOfficialSource + + /** + * 本轮取址彻底失败(官方源与自建源都没给出可用地址)→ 触发用户可见提示 + * 「远程源已全部失效,等待新版本修复」(沿用 [Eac19WindowGuard] 的 15 分钟节流)。 + */ + @JvmStatic + fun notifyAllSourcesFailed() { + Eac19WindowGuard.noteAllSourcesFailed() + } +} diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index e81f8cb..7565a26 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -50,19 +50,15 @@ object SongUrlResolver { private const val CONFIG_TTL_MS = 30 * 60_000L /** - * 歌曲源入口默认值:**自建源优先、官方源兜底**(2026-10-14)。 + * 歌曲源入口默认值:**官方源优先、自建源兜底**(2026-10-14 反转)。 * - * * `https://mdmuse.kasugano.cn` —— 自建歌曲源(IIS 反代 HTTPS;媒体站 `md.kasugano.cn:4433`, - * 直连内网时是 `http://192.168.100.232:6677`,媒体地址为 `/media/<编号>.ts`); - * * `http://zk.ktv1.top:6677` —— 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。 + * ⚠️ **单一来源**:直接引用 [SourceHosts.DEFAULT](不再各写一份字面量)。 + * 另一处字面量在 `assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(JS 无法引用 Kotlin 常量), + * 由 [`SourceHostsTest`] 与 `tools/check_source_hosts.py` 锁住一致性。 * - * ⚠️ 与 `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", - ) + private val DEFAULT_HOSTS = SourceHosts.DEFAULT /** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */ private val hostOrder: MutableList = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList()) @@ -150,6 +146,15 @@ object SongUrlResolver { /** 当前运行时源列表(只读快照,供日志/自检)。 */ fun currentHosts(): List = synchronized(hostOrder) { hostOrder.toList() } + /** + * **默认**源列表(不受运行时 promote/demote 与设置项影响)。 + * + * 就是 [SourceHosts.DEFAULT](单一来源),单独暴露是为了让"源顺序三处一致"的自检/单测 + * ([SourceHostsTest] 与 `tools/check_source_hosts.py`、JS 的 `CONFIG.HOSTS`)能直接读它。 + */ + @JvmStatic + fun defaultHosts(): List = DEFAULT_HOSTS + private fun demote(host: String) { synchronized(hostOrder) { val index = hostOrder.indexOf(host) @@ -214,8 +219,18 @@ object SongUrlResolver { } } - /** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */ - fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? { + /** 依次尝试各源,返回可用的播放地址;全部失败返回 null([resolveSourced] 的字符串版)。 */ + fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? = + resolveSourced(musicno, resolution, h265)?.url + + /** + * 依次尝试各源,返回**可用的播放地址 + 产出它的源**(全部失败返回 null)。 + * + * 为什么要带来源:汇合点([SongApiClient.getSongDownloadUrl])只在"地址来自官方源"时 + * 上报自建服务器(`POST /push_download`)。来源由这里**显式返回**, + * 不由调用方读 `lastHost` 之类的环境状态、更不靠 URL 字符串猜。 + */ + fun resolveSourced(musicno: String, resolution: String = "720", h265: Boolean = false): SourcedSongUrl? { if (musicno.isBlank()) return null val channel = fetch ?: run { MiliLog.localWarn("NATIVE-SONG 未安装取址通道,跳过原生兜底") @@ -273,7 +288,7 @@ object SongUrlResolver { "NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " + "第${index + 1}个源 用时=${elapsed}ms", ) - return url + return SourcedSongUrl(url, host) } lastDetail = when { url.isNullOrEmpty() -> "no-url" diff --git a/app/src/main/java/com/local/ktv/SourceHosts.kt b/app/src/main/java/com/local/ktv/SourceHosts.kt index 74c3056..185a419 100644 --- a/app/src/main/java/com/local/ktv/SourceHosts.kt +++ b/app/src/main/java/com/local/ktv/SourceHosts.kt @@ -10,9 +10,12 @@ import java.util.LinkedHashSet * 两边的**默认值必须逐项一致**、且都要能被设置项覆盖(见 [KtvStore.sourceHosts])。 * 解析/归一化这类"没有 IO 的判断"全部收敛到这里,JS 侧只负责"接收注入的数组"。 * - * ## 2026-10-14 自建源上线后的默认值 - * 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**排在第一位**, - * 官方源 `http://zk.ktv1.top:6677` 兜底 —— 自建源取不到地址时仍能回落到官方源,不影响老用户。 + * ## 2026-10-14 取址改为「官方源优先、自建源兜底」 + * 官方源 `http://zk.ktv1.top:6677` **排在第一位**(成功率与稳定性最好的入口), + * 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**兜底** —— + * 官方源取不到地址时仍能回落,不影响老用户。 + * 只有"地址来自官方源"才把该地址上报给自建服务器(见 [SourcedSongUrl.fromOfficialSource] + * 与 `SongApiClient.getSongDownloadUrl`);自建源自己给出的地址**不上报**。 * * ⚠️ 允许清单([DEFAULT_ALLOWED_MEDIA_HOSTS])只放行"**必须是域名**"这一条限制 * (直连内网自建源时媒体地址就是裸 IP:`http://192.168.100.232:6677/media/<编号>.ts`); @@ -22,13 +25,27 @@ import java.util.LinkedHashSet object SourceHosts { /** - * 默认源入口列表:**自建源优先、官方源兜底**。 + * 默认源入口列表:**官方源优先、自建源兜底**。 * * ⚠️ 与 `app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(以及 `CONFIG.HOST`) - * **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本)。 + * **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本 + * `tools/check_source_hosts.py`)。`SongUrlResolver.DEFAULT_HOSTS` 已收敛为**引用本常量** + * (不再各写一份),三处实际只剩两处字面量。 */ @JvmField val DEFAULT = listOf( + "http://zk.ktv1.top:6677", + "https://mdmuse.kasugano.cn", + ) + + /** + * **1.6.1(已打包、未发布)的旧默认顺序**:自建源优先、官方源兜底。 + * + * 只用于一次性迁移:已落盘的 `state.json` 里 `sourceHosts` 恰好等于它时, + * 启动时改成 [DEFAULT](见 [migrateLegacyDefault])。**用户自定义的列表一律不动**。 + */ + @JvmField + val LEGACY_DEFAULT = listOf( "https://mdmuse.kasugano.cn", "http://zk.ktv1.top:6677", ) @@ -84,6 +101,18 @@ object SourceHosts { @JvmStatic fun parseAllowed(text: String?): List = parseAllowedList(text?.split(SEPARATORS)) + /** + * 把**已落盘**的源列表迁移到当前默认顺序: + * 只有"恰好等于 [LEGACY_DEFAULT](1.6.1 的旧默认)"时才换成 [DEFAULT], + * 其余(含用户自定义、含空 → 已由 [parseList] 回落默认)原样返回。 + * + * 为什么需要它:1.6.1 已打包(虽然未发布)并可能已装到测试设备上, + * 其 `state.json` 里存着"自建源优先"的顺序 —— 不迁移的话新默认值在这些设备上不生效。 + */ + @JvmStatic + fun migrateLegacyDefault(hosts: List?): List = + if (hosts == LEGACY_DEFAULT) DEFAULT else hosts ?: DEFAULT + /** 归一化整份允许清单;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS]。 */ @JvmStatic fun parseAllowedList(items: Collection?): List { @@ -110,14 +139,37 @@ object SourceHosts { return host.trim().lowercase().ifEmpty { null } } + /** + * 该**主机名**(可以带端口/scheme,也可以是纯主机名)是不是自建源: + * 公网 API 站 `mdmuse.kasugano.cn` 与内网直连 `192.168.100.232` 都是同一个服务端。 + * + * ⚠️ 归一化复用 [normalizeAllowedHost](去 scheme/端口/路径/用户信息、小写), + * 因此传"源入口 URL"或"裸主机名"都能正确判定。 + */ + @JvmStatic + fun isSelfHostName(host: String?): Boolean { + val value = normalizeAllowedHost(host) ?: return false + return value == SELF_HOST || value == SELF_HOST_LAN + } + /** * 该源入口是不是**自建源**(只看主机名,与 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 + fun isSelfHost(url: String?): Boolean = isSelfHostName(url) + + /** + * 该源入口/主机名是不是**官方源**(=非自建源的源)——决定"成功取址后是否上报自建服务器"。 + * + * 需求:**只有地址来自官方源时才上报**(`POST /push_download` 让自建服务器留存该曲目); + * 地址来自自建源时不上报(它本来就有这首),DB 兜底(主机名为空)同样不上报。 + * 判定与"是不是自建源"互补,**不做 URL 字符串猜测**。 + */ + @JvmStatic + fun isOfficialSource(source: String?): Boolean { + val value = normalizeAllowedHost(source) ?: return false + return value != SELF_HOST && value != SELF_HOST_LAN } /** diff --git a/app/src/test/java/com/local/ktv/AdVideoFilterTest.java b/app/src/test/java/com/local/ktv/AdVideoFilterTest.java index 08b20d5..bb53e65 100644 --- a/app/src/test/java/com/local/ktv/AdVideoFilterTest.java +++ b/app/src/test/java/com/local/ktv/AdVideoFilterTest.java @@ -144,4 +144,19 @@ public final class AdVideoFilterTest { assertNull(AdVideoFilter.tsBasename( AdVideoFilter.pathOf("http://txog.ktvsky.com/a/stream?token=x"))); } + + /** + * `resolve()` 只有"屏蔽"一种语义:命中广告 → null,其余原样返回。 + * + * 原来的「替换成自己的视频」开关 `replacementUrl` 全仓没有任何写入点(恒为 null), + * 2026-10-14 已删除;该用例锁住"删除前后行为一致"。 + */ + @Test + public void resolveShieldsAdsAndPassesRealUrls() { + assertNull(AdVideoFilter.resolve("http://gz.ac16.vip/ad_files/my_ad_video.ts")); + assertNull(AdVideoFilter.resolve("https://wb66.cn/f/d/7b7c9/10188555.ts")); + String real = "http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7200088.ts?token=x"; + assertEquals(real, AdVideoFilter.resolve(real)); + assertNull(AdVideoFilter.resolve(null)); + } } diff --git a/app/src/test/java/com/local/ktv/DownloadEntryGateTest.java b/app/src/test/java/com/local/ktv/DownloadEntryGateTest.java new file mode 100644 index 0000000..be8416e --- /dev/null +++ b/app/src/test/java/com/local/ktv/DownloadEntryGateTest.java @@ -0,0 +1,65 @@ +package com.local.ktv; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; + +import org.junit.Test; + +/** + * 「地址闸门覆盖全部消费点」的静态回归(需求二 隐患 4 / 隐患 6)。 + * + * 这两条都是"某个入口绕过/遗留"的问题,纯逻辑单测测不到,只能对源码做**结构性断言**: + * * 隐患 4:`MainActivity.download(song)` 是四条取址路径之外的**第五条地址消费点**, + * 历史实现直接拿 `song.downloadUrl`/`videoUrl` 下载、完全不过闸门 → + * 现在必须过 `AdVideoFilter.isTrustedSongUrl`,不可信时走 + * `Eac19WindowGuard.noteAllSourcesFailed()`(「远程源已全部失效」); + * * 隐患 6:`streamDownloadAndPlay` 全仓无调用(死代码,且内部那条取址调用不过最终闸门)→ 已删除。 + */ +public class DownloadEntryGateTest { + + /** 取某个 `private fun (...)` 的函数体(到下一个同级 `private fun` 为止)。 */ + private static String functionBody(String source, String signature) { + int start = source.indexOf(signature); + assertTrue("在 MainActivity.kt 里找不到 " + signature, start >= 0); + int end = source.indexOf("\n private fun ", start + signature.length()); + return end < 0 ? source.substring(start) : source.substring(start, end); + } + + /** 隐患 4:备用下载入口必须过地址闸门,且不可信时按「源失效」处理。 */ + @Test + public void backupDownloadEntryGoesThroughAddressGate() throws IOException { + String source = RepoFiles.mainActivityKt(); + String body = functionBody(source, "private fun download(song: Song) {"); + assertTrue("download(song) 必须过 AdVideoFilter.isTrustedSongUrl 闸门", + body.contains("AdVideoFilter.isTrustedSongUrl(mainUrl, extractTid(song))")); + assertTrue("地址不可信时必须走「源失效」提示路径", + body.contains("Eac19WindowGuard.noteAllSourcesFailed()")); + // 闸门必须在真正发起下载(登记任务)之前。 + int gate = body.indexOf("!AdVideoFilter.isTrustedSongUrl(mainUrl"); + int task = body.indexOf("val task = DownloadTask(song)"); + assertTrue("闸门必须位于下载动作之前(gate=" + gate + ", task=" + task + ")", + gate >= 0 && task > gate); + } + + /** 隐患 6:死代码 `streamDownloadAndPlay` 已删除,且不再被文档/注释当作"现存路径"引用。 */ + @Test + public void deadStreamingPathIsRemoved() throws IOException { + String source = RepoFiles.mainActivityKt(); + assertFalse("MainActivity 里不应再有 streamDownloadAndPlay", + source.contains("streamDownloadAndPlay")); + } + + /** 主汇合点(SongApiClient)仍然存在,且"是否上报"由来源标志决定而不是 URL 猜测。 */ + @Test + public void reportDecisionComesFromExplicitSourceFlag() throws IOException { + String client = RepoFiles.read(RepoFiles.find( + "src/main/java/com/local/ktv/SongApiClient.kt", + "app/src/main/java/com/local/ktv/SongApiClient.kt")); + assertTrue(client.contains("SongSourceFlow.shouldReportToSelfHost(resolved)")); + assertTrue(client.contains("SongUrlResolver.resolveSourced(")); + assertTrue("DB 回落必须记一条可区分的本地告警(隐患 5)", + client.contains("SONG-DB 查询 cloud_url 异常")); + } +} diff --git a/app/src/test/java/com/local/ktv/JsBridgeGateTest.java b/app/src/test/java/com/local/ktv/JsBridgeGateTest.java new file mode 100644 index 0000000..3d23e83 --- /dev/null +++ b/app/src/test/java/com/local/ktv/JsBridgeGateTest.java @@ -0,0 +1,119 @@ +package com.local.ktv; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * JS 桥路径的三条回归断言(需求二 隐患 1/2/3)。 + * + * 1. `isTrustedDownloadUrl` 的 CDN 后缀白名单**不再含广告域名 `ac16.vip`**, + * 且判定改为**黑名单优先**(不依赖调用顺序); + * 2. `riskForbidden` 同时识别 **HTTP 403** 与 **HTTP 200 + 业务码 403**, + * 与原生 `SongUrlResolver.sawForbidden` 口径一致; + * 3. 保存设置后注入的脚本会**立即**改写 `KtvApiConfig.*` 与已构造实例的 `sourceOrder`。 + */ +public class JsBridgeGateTest { + + private static final String REAL_URL = + "http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7303235.ts?token=abc&t=1"; + + // ── 隐患 3:白名单里的广告域名 + 判定顺序 ── + + /** 广告域名 `*.ac16.vip` 必须被拒 —— 包括"文件名看起来完全正常"的那些(黑名单优先)。 */ + @Test + public void adHostIsRejectedEvenWithoutAdPathMarkers() { + assertFalse(KtvJsBridge.isTrustedDownloadUrl("http://gz.ac16.vip/ad_files/my_ad_video.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("http://gz.ac16.vip/7303235.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("https://ac16.vip/7303235.ts?token=abc")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("https://cdn.ac16.vip/x/7303235.ts")); + // 旧实现靠"调用方先跑 AdVideoFilter.resolve()"才拦得住:这里直接调闸门也必须拒。 + } + + /** 其它广告/测试形态同样被拒(wb66 / demo / R2 / 裸 IP / 非 http(s))。 */ + @Test + public void demoAndBareIpTargetsAreRejected() { + assertFalse(KtvJsBridge.isTrustedDownloadUrl("https://wb66.cn/f/d/7b7c9/10188555.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("http://demo.example.com/x/1.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("https://pub-abc123.r2.dev/ss/1.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("http://203.0.113.9/1.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl("file:///sdcard/1.ts")); + assertFalse(KtvJsBridge.isTrustedDownloadUrl(null)); + } + + /** 真源 CDN 与自建源媒体地址必须放行(自建源靠媒体地址允许清单)。 */ + @Test + public void realSourcesStillPass() { + assertTrue(KtvJsBridge.isTrustedDownloadUrl(REAL_URL)); + assertTrue(KtvJsBridge.isTrustedDownloadUrl( + "http://download.origjoy.com/264/lvbr/480/E/ts/39.6/x/480p/7445418.ts?token=x")); + // 自建源媒体站(HTTPS + 非标准端口):命中默认允许清单 + assertTrue(KtvJsBridge.isTrustedDownloadUrl("https://md.kasugano.cn:4433/media/7303235.ts")); + // 内网直连自建源:裸 IP,但命中允许清单 → 放行 + assertTrue(KtvJsBridge.isTrustedDownloadUrl("http://192.168.100.232:6677/media/7303235.ts")); + } + + // ── 隐患 2:业务码 403 的风控信号 ── + + /** HTTP 200 + `{"code":403}`(源站风控真实形态)必须被识别为风控。 */ + @Test + public void businessCode403IsRecognizedAsRiskControl() { + assertTrue(KtvJsBridge.riskForbidden(403, "")); + assertTrue(KtvJsBridge.riskForbidden(200, + "{\"code\":403,\"msg\":\"请求被拒绝:检测到异常行为\"}")); + assertTrue(KtvJsBridge.riskForbidden(200, "{\"code\": 403 }")); + } + + /** 业务码 404/200、HTTP 5xx、以及形如 4031 的其它业务码都**不是**风控。 */ + @Test + public void nonRiskResponsesAreNotFlagged() { + assertFalse("自建源没有这首歌(业务码 404)不是风控", KtvJsBridge.riskForbidden(200, + "{\"code\":404,\"msg\":\"not in library\"}")); + assertFalse(KtvJsBridge.riskForbidden(200, "{\"code\":200,\"data\":\"http://x/1.ts\"}")); + assertFalse(KtvJsBridge.riskForbidden(200, "{\"code\":4031}")); + assertFalse(KtvJsBridge.riskForbidden(500, "")); + assertFalse(KtvJsBridge.riskForbidden(200, "")); + assertFalse(KtvJsBridge.riskForbidden(200, null)); + assertFalse(KtvJsBridge.riskForbidden(0, null)); + } + + // ── 隐患 1:设置页改源后对 JS **立即生效** ── + + /** 注入脚本必须同时改写 `KtvApiConfig.*`(下次构造)与 `sourceOrder`(当前实例)。 */ + @Test + public void applySourceSettingsScriptTakesEffectImmediately() { + String script = KtvJsBridge.buildApplySourceSettingsJs( + SourceHosts.DEFAULT, SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS); + assertTrue("必须把新源列表注入脚本", + script.contains("var hosts = [" + + "\"http://zk.ktv1.top:6677\",\"https://mdmuse.kasugano.cn\"]")); + assertTrue("必须覆写 CONFIG.HOSTS", + script.contains("window.KtvApiConfig.HOSTS = hosts;")); + assertTrue("必须覆写 CONFIG.HOST", + script.contains("window.KtvApiConfig.HOST = hosts.length ? hosts[0] : ''")); + assertTrue("必须覆写允许清单", + script.contains("var allowed = [\"md.kasugano.cn\",\"192.168.100.232\"]")); + assertTrue("必须覆写允许清单", + script.contains("window.KtvApiConfig.ALLOWED_MEDIA_HOSTS = allowed;")); + assertTrue("必须重置已构造实例的源顺序(原 bug:只改配置不重置会话)", + script.contains("window.api.sourceOrder = hosts.slice();")); + // 自定义列表也照原样注入(顺序敏感) + String custom = KtvJsBridge.buildApplySourceSettingsJs( + java.util.Arrays.asList("http://127.0.0.1:1", "http://zk.ktv1.top:6677"), + java.util.Collections.singletonList("md.kasugano.cn")); + assertTrue(custom.contains( + "var hosts = [\"http://127.0.0.1:1\",\"http://zk.ktv1.top:6677\"]")); + } + + /** 桥的取址脚本必须在返回地址前回传"命中的源"(否则上报判定只能靠可变全局)。 */ + @Test + public void getSongUrlScriptReportsHitSource() throws java.io.IOException { + String bridge = RepoFiles.ktvJsBridgeKt(); + assertTrue("注入脚本必须调用 android.onSongSource(requestId, host)", + bridge.contains("android.onSongSource(requestId, host || '')")); + assertTrue("必须读取 KtvApi 记录的命中源", + bridge.contains("window.api.lastSourceHost")); + } +} diff --git a/app/src/test/java/com/local/ktv/RepoFiles.java b/app/src/test/java/com/local/ktv/RepoFiles.java new file mode 100644 index 0000000..638b00e --- /dev/null +++ b/app/src/test/java/com/local/ktv/RepoFiles.java @@ -0,0 +1,68 @@ +package com.local.ktv; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +/** + * 单测里读取仓库源文件的小工具(用于"静态一致性/回归"断言)。 + * + * 为什么需要:源顺序三处一致、JS 与 Android 的 `KTV_BRIDGE_API` 标记一致、 + * "备用下载入口过闸门"这类约定,都是**跨文件**的契约,纯逻辑单测覆盖不到; + * 用文本比对把它们锁住,比"靠人记得同步改"可靠。 + * + * 工作目录:Gradle 的单元测试默认以模块目录(`app/`)为 cwd;这里额外向上逐级查找, + * 因此从仓库根目录、模块目录或 IDE 里跑都能定位到文件。 + */ +final class RepoFiles { + + private RepoFiles() {} + + /** 依次尝试若干相对路径,返回第一个存在的文件;都不存在则抛错(避免"测试默默地没测到")。 */ + static File find(String... relativePaths) { + File dir = new File("").getAbsoluteFile(); + for (int depth = 0; depth < 4 && dir != null; depth++) { + for (String relative : relativePaths) { + File candidate = new File(dir, relative); + if (candidate.isFile()) return candidate; + } + dir = dir.getParentFile(); + } + throw new IllegalStateException( + "找不到仓库文件(相对路径 " + String.join(" / ", relativePaths) + ",cwd=" + + new File("").getAbsolutePath() + ")"); + } + + static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + /** JS 主脚本(`ktv_api.js`)。 */ + static String ktvApiJs() throws IOException { + return read(find("src/main/assets/mobile/ktv_api.js", "app/src/main/assets/mobile/ktv_api.js")); + } + + /** 原生取址实现(`SongUrlResolver.kt`)。 */ + static String songUrlResolverKt() throws IOException { + return read(find("src/main/java/com/local/ktv/SongUrlResolver.kt", + "app/src/main/java/com/local/ktv/SongUrlResolver.kt")); + } + + /** JS 桥(`KtvJsBridge.kt`)。 */ + static String ktvJsBridgeKt() throws IOException { + return read(find("src/main/java/com/local/ktv/KtvJsBridge.kt", + "app/src/main/java/com/local/ktv/KtvJsBridge.kt")); + } + + /** 主界面(`MainActivity.kt`)。 */ + static String mainActivityKt() throws IOException { + return read(find("src/main/java/com/local/ktv/MainActivity.kt", + "app/src/main/java/com/local/ktv/MainActivity.kt")); + } + + /** 项目约定文档(`AGENTS.md`,工作区根目录的 `maidong-ktv/` 内)。 */ + static String agentsMd() throws IOException { + return read(find("AGENTS.md", "maidong-ktv/AGENTS.md", "../AGENTS.md")); + } +} diff --git a/app/src/test/java/com/local/ktv/SongSourceFlowTest.java b/app/src/test/java/com/local/ktv/SongSourceFlowTest.java new file mode 100644 index 0000000..6c44109 --- /dev/null +++ b/app/src/test/java/com/local/ktv/SongSourceFlowTest.java @@ -0,0 +1,109 @@ +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.assertSame; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.Test; + +/** + * 「官方源优先、自建源兜底 + 只有官方源成功才上报」的回归测试(需求一/需求二)。 + * + * 生产代码里这三件事分别落在: + * * 源顺序/回落链 → `SourceHosts.DEFAULT` + `SongSourceFlow.firstAvailable`(`SongApiClient` 的汇合点用它); + * * "是否上报" → `SongSourceFlow.shouldReportToSelfHost`(`SongApiClient.getSongDownloadUrl` 用它); + * * "全失败弹窗" → `SongSourceFlow.notifyAllSourcesFailed` → `Eac19WindowGuard.noteAllSourcesFailed`。 + * 这里用**生产函数本身**(不是测试里另写一套)走完需求里的三条分支。 + */ +public class SongSourceFlowTest { + + private static final String OFFICIAL_URL = + "http://txog.ktvsky.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7303235.ts?token=abc&t=1"; + private static final String SELF_URL = "https://md.kasugano.cn:4433/media/7303235.ts"; + + private static SourcedSongUrl official() { + return new SourcedSongUrl(OFFICIAL_URL, "http://zk.ktv1.top:6677"); + } + + private static SourcedSongUrl selfHost() { + return new SourcedSongUrl(SELF_URL, "https://mdmuse.kasugano.cn"); + } + + /** DB 回落的来源标志是空串 → 既不是官方源也不是自建源。 */ + private static SourcedSongUrl fromDb() { + return new SourcedSongUrl("https://pub.cdn.cherryonline.cn/video/cloud-song/7303235.ts", ""); + } + + /** 分支①:官方源成功 → 用官方地址,且**触发上报**。 */ + @Test + public void officialSuccessIsUsedAndReported() { + SourcedSongUrl picked = SongSourceFlow.firstAvailable( + () -> official(), () -> selfHost(), () -> fromDb()); + assertEquals(OFFICIAL_URL, picked.getUrl()); + assertTrue("官方源地址必须上报", SongSourceFlow.shouldReportToSelfHost(picked)); + assertTrue(picked.getFromOfficialSource()); + // 前一级成功后,后面的级不应被求值(懒执行) + final AtomicInteger laterCalls = new AtomicInteger(); + SongSourceFlow.firstAvailable(() -> official(), () -> { + laterCalls.incrementAndGet(); + return selfHost(); + }); + assertEquals("拿到地址后不得再跑后续回落级", 0, laterCalls.get()); + } + + /** 分支②:官方源失败 + 自建源有 → 用自建地址,且**不上报**。 */ + @Test + public void selfHostFallbackIsUsedAndNotReported() { + SourcedSongUrl picked = SongSourceFlow.firstAvailable( + () -> null, () -> selfHost(), () -> fromDb()); + assertEquals(SELF_URL, picked.getUrl()); + assertFalse("自建源取到的地址不上报", SongSourceFlow.shouldReportToSelfHost(picked)); + assertFalse(picked.getFromOfficialSource()); + } + + /** DB 兜底(主机名为空)也不上报 —— "来源不明"绝不推给服务器。 */ + @Test + public void dbFallbackIsNotReported() { + SourcedSongUrl db = fromDb(); + assertSame(db, SongSourceFlow.firstAvailable(() -> null, () -> null, () -> db)); + assertFalse(SongSourceFlow.shouldReportToSelfHost(db)); + assertFalse(SongSourceFlow.shouldReportToSelfHost(null)); + assertFalse("空地址不上报", SongSourceFlow.shouldReportToSelfHost(new SourcedSongUrl("", "http://zk.ktv1.top:6677"))); + } + + /** 分支③:官方与自建都失败 → 回落链返回 null,并触发「远程源已全部失效」回调。 */ + @Test + public void allSourcesFailedTriggersUserVisiblePrompt() { + assertNull(SongSourceFlow.firstAvailable(() -> null, () -> null, () -> null)); + + final AtomicInteger prompts = new AtomicInteger(); + Eac19WindowGuard.INSTANCE.setOnAllFailed(() -> { + prompts.incrementAndGet(); + return kotlin.Unit.INSTANCE; + }); + try { + SongSourceFlow.notifyAllSourcesFailed(); + assertEquals("必须触发一次「源失效」提示回调", 1, prompts.get()); + // 15 分钟节流:紧接着再来一次不再弹(避免每次点歌都弹) + SongSourceFlow.notifyAllSourcesFailed(); + assertEquals("同一 episode 内应节流", 1, prompts.get()); + } finally { + Eac19WindowGuard.INSTANCE.setOnAllFailed(null); + } + } + + /** 来源标志的判定本身:官方源入口/纯主机名都算官方,自建源(公网 + 内网)不算。 */ + @Test + public void sourceFlagClassification() { + assertTrue(official().getFromOfficialSource()); + assertFalse(selfHost().getFromOfficialSource()); + assertFalse(fromDb().getFromOfficialSource()); + assertTrue(new SourcedSongUrl("u", "zk.ktv1.top").getFromOfficialSource()); + assertFalse(new SourcedSongUrl("u", "http://192.168.100.232:6677").getFromOfficialSource()); + assertFalse(new SourcedSongUrl("u", "").getFromOfficialSource()); + } +} diff --git a/app/src/test/java/com/local/ktv/SourceHostsTest.java b/app/src/test/java/com/local/ktv/SourceHostsTest.java index 29df2f1..afdcecf 100644 --- a/app/src/test/java/com/local/ktv/SourceHostsTest.java +++ b/app/src/test/java/com/local/ktv/SourceHostsTest.java @@ -13,27 +13,30 @@ import java.util.List; import org.junit.Test; /** - * 歌曲源列表 / 媒体地址允许清单 / 推送默认目标的纯 JVM 单测。 + * 歌曲源列表 / 媒体地址允许清单 / 推送默认目标 / 旧默认值迁移的纯 JVM 单测。 * - * 覆盖需求点名的两组: - * ② 源列表解析(`sourceHosts` 为空时回落默认;含自建源优先); - * ④ 推送默认目标(`pushServerUrl` 为空且首源为自建源时使用它,否则尊重显式配置)。 + * 覆盖需求点名的几组: + * ② 源列表解析(`sourceHosts` 为空时回落默认;**官方源优先、自建源兜底**); + * ④ 推送默认目标(`pushServerUrl` 为空时从列表里找自建源,找不到才关闭); + * ③ 1.6.1 旧默认顺序(自建源优先)落盘值的一次性迁移。 */ public class SourceHostsTest { // ── ② 源列表解析 ── - /** 空(旧配置没有这个键 / 用户清空)→ 回落默认:**自建源第一、官方源兜底**。 */ + /** 空(旧配置没有这个键 / 用户清空)→ 回落默认:**官方源第一、自建源兜底**。 */ @Test - public void emptySourceListFallsBackToDefaultWithSelfHostFirst() { + public void emptySourceListFallsBackToDefaultWithOfficialFirst() { 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))); + assertEquals("http://zk.ktv1.top:6677", SourceHosts.DEFAULT.get(0)); + assertEquals("https://mdmuse.kasugano.cn", SourceHosts.DEFAULT.get(1)); + assertTrue("默认列表第一项必须是官方源", SourceHosts.isOfficialSource(SourceHosts.DEFAULT.get(0))); + assertFalse("默认列表第二项是自建源兜底", SourceHosts.isOfficialSource(SourceHosts.DEFAULT.get(1))); + assertTrue(SourceHosts.isSelfHost(SourceHosts.DEFAULT.get(1))); } /** 设置页"一行一个":去空白/尾斜杠、按顺序去重、忽略非 http(s) 的错行。 */ @@ -53,18 +56,47 @@ public class SourceHostsTest { assertEquals(2, SourceHosts.parse("https://a.example.com;http://b.example.com").size()); } - /** 自建源优先:自建源放第一 + 官方源兜底,两份实现(JS/原生)都读这个顺序。 */ + /** 官方源优先:官方源放第一 + 自建源兜底,两份实现(JS/原生)都读这个顺序。 */ @Test - public void selfHostFirstThenOfficialFallback() { + public void officialFirstThenSelfHostFallback() { 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))); + "http://zk.ktv1.top:6677", + "https://mdmuse.kasugano.cn")); + assertTrue(SourceHosts.isOfficialSource(hosts.get(0))); + assertFalse(SourceHosts.isOfficialSource(hosts.get(1))); + assertTrue(SourceHosts.isSelfHost(hosts.get(1))); // 主机名判定与 scheme/端口无关(内网直连也是自建源) assertTrue(SourceHosts.isSelfHost("http://mdmuse.kasugano.cn:6677")); + assertTrue(SourceHosts.isSelfHostName("192.168.100.232")); assertFalse(SourceHosts.isSelfHost(null)); assertFalse(SourceHosts.isSelfHost("")); + // 官方源判定:非自建源的非空主机名;空/畸形一律 false("来源不明"绝不上报) + assertTrue(SourceHosts.isOfficialSource("http://zk.ktv1.top:6677")); + assertTrue(SourceHosts.isOfficialSource("zk.ktv1.top")); + assertFalse(SourceHosts.isOfficialSource("")); + assertFalse(SourceHosts.isOfficialSource(null)); + assertFalse(SourceHosts.isOfficialSource("http://192.168.100.232:6677")); // 内网直连 = 自建源 + } + + // ── ③ 旧默认顺序迁移 ── + + /** 落盘值恰好等于 1.6.1 的旧默认(自建源优先)→ 迁移成"官方源优先"的新默认。 */ + @Test + public void legacySelfHostFirstOrderIsMigratedToNewDefault() { + List legacy = new ArrayList(SourceHosts.LEGACY_DEFAULT); + assertEquals("旧默认必须与 1.6.1 落盘顺序一致", + Arrays.asList("https://mdmuse.kasugano.cn", "http://zk.ktv1.top:6677"), legacy); + assertEquals(SourceHosts.DEFAULT, SourceHosts.migrateLegacyDefault(legacy)); + } + + /** 用户自定义的列表**一律不动**(哪怕顺序看起来像旧的)、空 → 默认。 */ + @Test + public void customOrderIsNeverRewritten() { + List custom = Arrays.asList("http://192.168.100.232:6677", "http://zk.ktv1.top:6677"); + assertEquals(custom, SourceHosts.migrateLegacyDefault(custom)); + List selfOnly = Collections.singletonList("https://mdmuse.kasugano.cn"); + assertEquals(selfOnly, SourceHosts.migrateLegacyDefault(selfOnly)); + assertEquals(SourceHosts.DEFAULT, SourceHosts.migrateLegacyDefault(null)); } // ── 允许清单解析 ── @@ -85,9 +117,9 @@ public class SourceHostsTest { // ── ④ 推送默认目标 ── + /** 留空 + 列表里有自建源 → 用它(**不要求它排第一**,新默认顺序里官方源才是第一)。 */ @Test - public void pushTargetAutoUsesFirstSelfHostSource() { - // 留空 + 首源是自建源 → 用它(自建源接入后的默认行为) + public void pushTargetAutoUsesSelfHostAnywhereInList() { assertEquals("https://mdmuse.kasugano.cn", PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT)); assertEquals("http://192.168.100.232:6677", @@ -95,6 +127,11 @@ public class SourceHostsTest { // 空配置也照样能拼出端点 assertEquals("https://mdmuse.kasugano.cn/push_download", PushDownload.endpoint(PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT))); + // 自建源不在第一位(新默认就是这样)也要能找到 + List officialFirst = Arrays.asList( + "http://zk.ktv1.top:6677", "https://mdmuse.kasugano.cn"); + assertEquals("https://mdmuse.kasugano.cn", + PushDownload.effectiveServerUrl("", officialFirst)); } @Test @@ -114,13 +151,12 @@ public class SourceHostsTest { } @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))); + public void pushTargetStaysOffWhenNoSelfHostSource() { + List officialOnly = new ArrayList(); + officialOnly.add("http://zk.ktv1.top:6677"); + assertEquals("列表里没有自建源 → 不自动开推送", "", + PushDownload.effectiveServerUrl("", officialOnly)); + assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("", officialOnly))); assertEquals("没有任何源 → 关闭", "", PushDownload.effectiveServerUrl("", Collections.emptyList())); // 配置非法(漏写协议)仍按关闭处理(沿用旧口径) diff --git a/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java b/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java new file mode 100644 index 0000000..a50e43d --- /dev/null +++ b/app/src/test/java/com/local/ktv/SourceOrderConsistencyTest.java @@ -0,0 +1,102 @@ +package com.local.ktv; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.Test; + +/** + * 「源顺序三处一致」的静态比对(需求一/需求四)。 + * + * 三处必须逐项一致: + * 1. `SourceHosts.DEFAULT`(单一来源,`KtvStore` 默认值也用它); + * 2. `SongUrlResolver.DEFAULT_HOSTS`(已收敛为**引用** `SourceHosts.DEFAULT`,不再各写一份); + * 3. `ktv_api.js` 的 `CONFIG.HOSTS`(**唯一剩下的字面量**,JS 没法引用 Kotlin 常量)。 + * 外加 `CONFIG.HOST` == `CONFIG.HOSTS[0]`,以及"官方源优先"的顺序本身。 + * + * 同时锁住 JS 与 Android 两侧的 `KTV_BRIDGE_API` 标记、以及 `AGENTS.md` 里的记录 + * (隐患 13:文档写 13、代码是 14 的历史漂移不能再来一次)。 + */ +public class SourceOrderConsistencyTest { + + private static final Pattern JS_LIST = Pattern.compile("%s\\s*:\\s*\\[(.*?)]", Pattern.DOTALL); + private static final Pattern JS_STRING = Pattern.compile("\\b%s\\s*:\\s*'([^']*)'"); + + private static List jsList(String js, String key) { + Matcher m = Pattern.compile(String.format(JS_LIST.pattern(), Pattern.quote(key)), Pattern.DOTALL).matcher(js); + assertTrue("ktv_api.js 里找不到 " + key, m.find()); + List out = new ArrayList(); + Matcher item = Pattern.compile("'([^']*)'").matcher(m.group(1)); + while (item.find()) out.add(item.group(1)); + return out; + } + + private static String jsString(String js, String key) { + Matcher m = Pattern.compile(String.format(JS_STRING.pattern(), Pattern.quote(key))).matcher(js); + assertTrue("ktv_api.js 里找不到 " + key, m.find()); + return m.group(1); + } + + /** 默认源顺序:官方源第一、自建源兜底;三处逐项一致;`CONFIG.HOST` == 首项。 */ + @Test + public void defaultSourceOrderIsOfficialFirstEverywhere() throws IOException { + List jsHosts = jsList(RepoFiles.ktvApiJs(), "HOSTS"); + List jsHost = new ArrayList(); + jsHost.add(jsString(RepoFiles.ktvApiJs(), "HOST")); + + assertEquals("三处源列表必须逐项一致(ktv_api.js / SongUrlResolver / SourceHosts)", + SourceHosts.DEFAULT, jsHosts); + assertEquals("SongUrlResolver.DEFAULT_HOSTS 必须与单一来源一致", + SourceHosts.DEFAULT, SongUrlResolver.defaultHosts()); + assertEquals("CONFIG.HOST 必须等于 CONFIG.HOSTS[0]", jsHost, jsHosts.subList(0, 1)); + + // 顺序本身:官方源优先、自建源兜底。 + assertEquals("http://zk.ktv1.top:6677", SourceHosts.DEFAULT.get(0)); + assertEquals("https://mdmuse.kasugano.cn", SourceHosts.DEFAULT.get(1)); + assertTrue(SourceHosts.isOfficialSource(SourceHosts.DEFAULT.get(0))); + assertTrue(SourceHosts.isSelfHost(SourceHosts.DEFAULT.get(1))); + } + + /** `SongUrlResolver` 不再自己写一份字面量,而是引用 `SourceHosts.DEFAULT`(收敛为单一来源)。 */ + @Test + public void resolverDefaultHostsComeFromSourceHosts() throws IOException { + String resolver = RepoFiles.songUrlResolverKt(); + assertTrue("DEFAULT_HOSTS 应引用 SourceHosts.DEFAULT", + resolver.contains("private val DEFAULT_HOSTS = SourceHosts.DEFAULT")); + // 收敛后,解析器文件里不应再出现源入口的**字符串字面量** + // (否则又变成两份"必须手工同步"的常量;注释里提到域名不影响判定)。 + assertTrue("SongUrlResolver 里不应再出现源入口字面量", + !resolver.contains("\"http://zk.ktv1.top:6677\"") + && !resolver.contains("\"https://mdmuse.kasugano.cn\"")); + } + + /** 媒体地址允许清单两处一致(JS `CONFIG.ALLOWED_MEDIA_HOSTS` vs `SourceHosts`)。 */ + @Test + public void allowedMediaHostsMatchJs() throws IOException { + assertEquals(SourceHosts.DEFAULT_ALLOWED_MEDIA_HOSTS, + jsList(RepoFiles.ktvApiJs(), "ALLOWED_MEDIA_HOSTS")); + } + + /** + * `KTV_BRIDGE_API` 标记三处一致:`ktv_api.js` 注释与导出常量、`KtvJsBridge` 的两处校验, + * 外加 `AGENTS.md` 的记录(隐患 13:文档与代码漂移过一次)。 + */ + @Test + public void bridgeApiMarkerIsConsistent() throws IOException { + String js = RepoFiles.ktvApiJs(); + assertTrue("ktv_api.js 缺少 KTV_BRIDGE_API: 15 注释标记", + js.contains("KTV_BRIDGE_API: 15")); + assertTrue("ktv_api.js 导出的版本号应为 15", + js.contains("global.KtvBridgeApiVersion = 15")); + assertTrue("KtvJsBridge 的校验标记应为 15", + RepoFiles.ktvJsBridgeKt().contains("BRIDGE_API_MARKER = \"KTV_BRIDGE_API: 15\"")); + assertTrue("AGENTS.md 里的 KTV_BRIDGE_API 记录必须与代码一致(15)", + RepoFiles.agentsMd().contains("已升到 **15**")); + } +}