取址改为官方源优先+自建源兜底,只有官方源成功才上报(V1.6.2/45,未发布)
需求一:流程反转 + 上报绑定官方源成功 - 默认 sourceHosts 反转为 ["http://zk.ktv1.top:6677", "https://mdmuse.kasugano.cn"]; SourceHosts.DEFAULT / SongUrlResolver.DEFAULT_HOSTS(收敛为引用前者)/ ktv_api.js CONFIG.HOSTS+HOST 三处一致。 - 取址结果改为 SourcedSongUrl(url, sourceHost)(**显式来源标志**,不靠 URL 猜): 新增 SongSourceFlow(回落链 firstAvailable / 上报判定 shouldReportToSelfHost / 全失败信号);JS 侧新增 android.onSongSource(requestId, host) 回传命中的源。 - 汇合点 SongApiClient.getSongDownloadUrl 只在"地址来自官方源"时才 POST /push_download; 自建源自己给的地址与 DB 回落都不上报。 - 推送默认目标改为"从歌曲源列表里找第一个自建源"(自建源不再排第一,旧写法会直接关掉推送)。 - 1.6.1 落盘的旧默认顺序(自建源优先)在 KtvStore.load() 里一次性迁移;自定义列表不动。 - 业务码 404(自建源没有这首歌)仍只当"该源没地址",不置 sawForbidden、不进冷却、不污染受限统计。 需求二:6 条隐患 1) 设置页改源对 JS 立即生效:KtvJsBridge.applySourceSettingsNow 重新注入 KtvApiConfig.* 并把已构造实例的 window.api.sourceOrder 重置为新列表;SongApiClient.applySourceSettings 接线。 2) JS 路径识别业务码 403:新增 KtvJsBridge.riskForbidden(status, body)(纯函数,手写判定 避免 org.json 在单测里是空壳),HTTP 403 与 HTTP 200+{"code":403} 都算风控,与原生 sawForbidden 同口径。 3) isTrustedDownloadUrl 移除广告域名 ac16.vip 白名单,并改为黑名单优先(不再依赖调用顺序)。 4) MainActivity.download(song)(第五条地址消费点)补 AdVideoFilter.isTrustedSongUrl 闸门, 不可信按"源失效"处理(Eac19WindowGuard.noteAllSourcesFailed),不再静默下载。 5) SongApiClient.fetchFromDb 不再静默吞异常:曲库打不开/查询异常各记一条 MiliLog.localWarn(含编号与异常)。 6) 删除死代码 streamDownloadAndPlay(全仓 grep 无调用点,且内部取址不过最终闸门)。 需求三:死代码/漂移清理(均先全仓搜索确认无引用) - AdVideoFilter.replacementUrl 无任何写入点(恒为 null)→ 删除,resolve() 只保留"屏蔽"语义。 - ktv_api.js CONFIG.VER/VN 由"从不生效的死值"改为实际使用值 2.0 / 4.1.3.03281430。 - CONFIG.HOST 改为被 getToken 真正读取(不再"注入但从未被读")。 - KTV_BRIDGE_API 14 → 15(JS 两处 + KtvJsBridge 两处校验);AGENTS.md / README.md / README.en.md 的陈旧标记统一到 15。 - do.php 的 limit 与 i.php 的 retry/ordertype/end_date/heart_time 确认无消费方 → 不新增解析(保持现状)。 需求四:测试与打包 - 新增 SourceOrderConsistencyTest / SongSourceFlowTest / JsBridgeGateTest / DownloadEntryGateTest, 更新 SourceHostsTest / AdVideoFilterTest;app/build.gradle 打开 unitTests.returnDefaultValues。 - testReleaseUnitTest --rerun-tasks --offline:12 个测试类 / 87 例全过。 - assembleRelease --offline 通过(lintVital NewApi 0 条);版本 1.6.1/44 → 1.6.2/45。 不发布:未推 Gitea/Gitee、未动 Release/tag。
This commit is contained in:
@@ -195,8 +195,10 @@ $BT/apksigner verify 目标名.apk
|
|||||||
|
|
||||||
### 版本号
|
### 版本号
|
||||||
|
|
||||||
- `app/build.gradle`:`versionName "1.6.0"` / `versionCode 43`
|
- `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 支线,均尚未发布。18~21 曾被本机临时诊断包占用)
|
(`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)` 括号)
|
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||||
- ⚠️ **本机 `/tmp` 是 tmpfs,单测必须绕开它**:`/tmp`(5.6G tmpfs)长期被"已删除但仍被打开"的
|
- ⚠️ **本机 `/tmp` 是 tmpfs,单测必须绕开它**:`/tmp`(5.6G tmpfs)长期被"已删除但仍被打开"的
|
||||||
文件占满,单测写临时文件会抛 `IOException: 超出磁盘配额`(EDQUOT),**看起来像测试失败**。
|
文件占满,单测写临时文件会抛 `IOException: 超出磁盘配额`(EDQUOT),**看起来像测试失败**。
|
||||||
@@ -331,6 +333,27 @@ $BT/apksigner verify 目标名.apk
|
|||||||
裸 socket 会正确使用 `uri.port`(6677)—— 以前只有域名源,改这块要注意别丢端口。
|
裸 socket 会正确使用 `uri.port`(6677)—— 以前只有域名源,改这块要注意别丢端口。
|
||||||
- `AdVideoFilter.AD_HOSTS`(媒体层广告域名黑名单)**不变**:它管的是"返回地址"的 host,
|
- `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`)显示原版客户端是**三步**:
|
- **⚠️ 原版协议对齐(2026-10-08,依据 HAR 抓包实测)**:抓包(Reqable,`e.ac19.cn`)显示原版客户端是**三步**:
|
||||||
1. **`POST /api.php`**:`Content-Type: application/x-www-form-urlencoded`,
|
1. **`POST /api.php`**:`Content-Type: application/x-www-form-urlencoded`,
|
||||||
body `action=get_config&device_id=<mac>`,UA
|
body `action=get_config&device_id=<mac>`,UA
|
||||||
@@ -398,7 +421,7 @@ $BT/apksigner verify 目标名.apk
|
|||||||
旧实现在 `#fragment` 上会**整条文件名规则失效(漏放广告)**,拿整串搜 `wb66`/`demo`
|
旧实现在 `#fragment` 上会**整条文件名规则失效(漏放广告)**,拿整串搜 `wb66`/`demo`
|
||||||
又会误杀 query 里的签名串("地址不可信"的隐蔽来源)。裸 IP / `pub-*.r2.dev/ss/`
|
又会误杀 query 里的签名串("地址不可信"的隐蔽来源)。裸 IP / `pub-*.r2.dev/ss/`
|
||||||
也已收进这道闸门 —— 四条路径不再各判一套。JS 侧同步改(`splitUrl()`),
|
也已收进这道闸门 —— 四条路径不再各判一套。JS 侧同步改(`splitUrl()`),
|
||||||
`KTV_BRIDGE_API` 已升到 **13**;回归用例见 `AdVideoFilterTest`(带 query/fragment 的
|
`KTV_BRIDGE_API` 已升到 **15**;回归用例见 `AdVideoFilterTest`(带 query/fragment 的
|
||||||
真源放行、广告仍拒)与服务端 `tests/test_basic.py::TestQueryStringGate`;
|
真源放行、广告仍拒)与服务端 `tests/test_basic.py::TestQueryStringGate`;
|
||||||
3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整),
|
3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整),
|
||||||
所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件**
|
所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件**
|
||||||
@@ -652,8 +675,10 @@ $BT/apksigner verify 目标名.apk
|
|||||||
唯一例外是"马上要 `play()` 下一首"的路径(可以不停,避免切歌瞬间静音)。
|
唯一例外是"马上要 `play()` 下一首"的路径(可以不停,避免切歌瞬间静音)。
|
||||||
- **⚠️ 「切歌 → 公放」在老盒子上卡死(2026-09-26,极米 H1S)**:新设备正常、老设备卡死,
|
- **⚠️ 「切歌 → 公放」在老盒子上卡死(2026-09-26,极米 H1S)**:新设备正常、老设备卡死,
|
||||||
排查出**三个叠加原因**,都已修掉,改动这块时必须同时守住:
|
排查出**三个叠加原因**,都已修掉,改动这块时必须同时守住:
|
||||||
1. **不能共用 `io` 线程池**:`io` 只有 4 个线程,且"点歌即下即播"(`streamDownloadAndPlay`)
|
1. **不能共用 `io` 线程池**:`io` 只有 4 个线程,且"点歌即下即播"(当时是 `streamDownloadAndPlay`,
|
||||||
这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满,切歌任务一直排队。
|
该函数已作为死代码在 1.6.2 删除;现在这条链路是 `startDownloadAndPlay` → `downloadSong`
|
||||||
|
→ `SongOkDownloadManager`)这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满,
|
||||||
|
切歌任务一直排队。
|
||||||
→ 切歌/随机续播改用 `randomPlayExecutor`(专用单线程守护线程)。
|
→ 切歌/随机续播改用 `randomPlayExecutor`(专用单线程守护线程)。
|
||||||
2. **不能现扫目录找候选**:候选来自 `localSongInventory()` + `library.scanLocal()`,
|
2. **不能现扫目录找候选**:候选来自 `localSongInventory()` + `library.scanLocal()`,
|
||||||
代价是"每个文件在头尾各读约 4.3MB 做 TS 校验" + "遍历 `/storage/ 各挂载点`
|
代价是"每个文件在头尾各读约 4.3MB 做 TS 校验" + "遍历 `/storage/ 各挂载点`
|
||||||
|
|||||||
+1
-1
@@ -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.
|
(`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
|
- **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.
|
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
|
## Building
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
- **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖代码托管仓库承载数据库。
|
- **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖代码托管仓库承载数据库。
|
||||||
- **歌曲源**:接入 `w.w345.my`(`i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。
|
- **歌曲源**:接入 `w.w345.my`(`i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。
|
||||||
- **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。
|
- **风控应对**:源对陌生出口 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` 时提示下载。
|
- **应用更新**:以 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`;已在设置页提供「重置数据库」。
|
- **曲库初始化失败**:检查设备网络能否访问 `mconn.cherryonline.cn`;已在设置页提供「重置数据库」。
|
||||||
- **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。
|
- **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。
|
||||||
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 8` 标记,否则内置版本优先。
|
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 15` 标记,否则内置版本优先。
|
||||||
- **想改签名/ABI**:编辑 `app/build.gradle` 的 `signingConfigs` 与 `ndk.abiFilters` 后重新编译。
|
- **想改签名/ABI**:编辑 `app/build.gradle` 的 `signingConfigs` 与 `ndk.abiFilters` 后重新编译。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|||||||
+12
-2
@@ -20,8 +20,8 @@ android {
|
|||||||
applicationId "com.local.ktv"
|
applicationId "com.local.ktv"
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 25
|
targetSdk 25
|
||||||
versionCode 44
|
versionCode 45
|
||||||
versionName "1.6.1"
|
versionName "1.6.2"
|
||||||
ndk {
|
ndk {
|
||||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||||
@@ -81,6 +81,16 @@ android {
|
|||||||
buildConfig = true
|
buildConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
// 纯 JVM 单测里 android.jar 是空壳:Log.* 之类默认会抛 "not mocked"。
|
||||||
|
// 打开后存根方法返回默认值(Log.w → 0),这样"只记日志、判定另有断言"的
|
||||||
|
// 生产代码(如 Eac19WindowGuard / AdVideoFilter.resolve)也能被直接单测。
|
||||||
|
// ⚠️ 它只影响"存根返回什么",不改变任何判定逻辑。
|
||||||
|
returnDefaultValues = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
|||||||
@@ -12,25 +12,29 @@
|
|||||||
(function(global) {
|
(function(global) {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// KTV_BRIDGE_API: 14 (Android 缓存校验标记,请勿删除)
|
// KTV_BRIDGE_API: 15 (Android 缓存校验标记,请勿删除)
|
||||||
|
|
||||||
// ─── 配置 ──────────────────────────────────────────
|
// ─── 配置 ──────────────────────────────────────────
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
APP_ID: 'd4eeacc6cec3434fbc8c41608a3056a0',
|
APP_ID: 'd4eeacc6cec3434fbc8c41608a3056a0',
|
||||||
APP_KEY: '024210cba40d4385a93e6c2d3249bfb5',
|
APP_KEY: '024210cba40d4385a93e6c2d3249bfb5',
|
||||||
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
|
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
|
||||||
VN: '4.100.5.11121504',
|
// ⚠️ 下面两个值与 App 侧**实际使用**的值一致(`KtvJsBridge` 注入时也是这两个)。
|
||||||
VER: '4',
|
// 旧文件里写的是 '4.100.5.11121504' / '4',那是**从不生效的死值** ——
|
||||||
// 歌曲源入口:**自建源优先、官方源兜底**(2026-10-14)。
|
// 被桥覆写后就成了历史遗留,直接读本文件会得出错误的协议版本结论。
|
||||||
// https://mdmuse.kasugano.cn = 自建歌曲源(App 令牌 / 点歌推送的宿主;媒体站 md.kasugano.cn:4433);
|
// 现在即使不经过桥注入(单独调试本文件)也是同一份协议版本。
|
||||||
// http://zk.ktv1.top:6677 = 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。
|
VN: '4.1.3.03281430',
|
||||||
// ⚠️ 与 Android 侧 SongUrlResolver.DEFAULT_HOSTS(SourceHosts.DEFAULT)**逐项一致**;
|
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)。
|
// 运行时由 App 设置项注入覆盖(window.KtvApiConfig.HOSTS / HOST)。
|
||||||
HOSTS: [
|
HOSTS: [
|
||||||
'https://mdmuse.kasugano.cn',
|
|
||||||
'http://zk.ktv1.top:6677',
|
'http://zk.ktv1.top:6677',
|
||||||
|
'https://mdmuse.kasugano.cn',
|
||||||
],
|
],
|
||||||
HOST: 'https://mdmuse.kasugano.cn',
|
HOST: 'http://zk.ktv1.top:6677',
|
||||||
// 媒体地址允许清单(App 设置项注入覆盖):只放行这些主机的"非域名"限制 ——
|
// 媒体地址允许清单(App 设置项注入覆盖):只放行这些主机的"非域名"限制 ——
|
||||||
// 直连内网自建源时媒体地址就是裸 IP(http://192.168.100.232:6677/media/<编号>.ts)。
|
// 直连内网自建源时媒体地址就是裸 IP(http://192.168.100.232:6677/media/<编号>.ts)。
|
||||||
// ⚠️ 广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video/pub-*.r2.dev/ss)优先级更高,命中清单也要拒。
|
// ⚠️ 广告黑名单(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();
|
this.sourceOrder = (options.hosts && options.hosts.length ? options.hosts : CONFIG.HOSTS).slice();
|
||||||
|
// 最近一次取址**命中的源**(成功时写入;失败/未开始时为空)。
|
||||||
|
// 供 Android 侧判定"这条地址是否来自官方源"(只有官方源成功才上报自建服务器)——
|
||||||
|
// 由桥在返回地址之前用 `android.onSongSource(requestId, host)` 显式取走。
|
||||||
|
this.lastSourceHost = '';
|
||||||
// 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。
|
// 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。
|
||||||
this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries))
|
this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries))
|
||||||
? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3;
|
? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3;
|
||||||
@@ -318,6 +326,7 @@
|
|||||||
this.sn = randomHex(16);
|
this.sn = randomHex(16);
|
||||||
this.tokens = {};
|
this.tokens = {};
|
||||||
this.token = '';
|
this.token = '';
|
||||||
|
this.lastSourceHost = '';
|
||||||
this._log('Device regenerated: ' + this.mac + '_' + this.sn);
|
this._log('Device regenerated: ' + this.mac + '_' + this.sn);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -355,7 +364,8 @@
|
|||||||
// 2. Get Auth Token
|
// 2. Get Auth Token
|
||||||
getToken: async function(host, forceRefresh) {
|
getToken: async function(host, forceRefresh) {
|
||||||
var self = this;
|
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]) {
|
if (!forceRefresh && self.tokens[host]) {
|
||||||
self.token = self.tokens[host];
|
self.token = self.tokens[host];
|
||||||
return self.token;
|
return self.token;
|
||||||
@@ -430,6 +440,9 @@
|
|||||||
var self = this;
|
var self = this;
|
||||||
var lastDetail = '';
|
var lastDetail = '';
|
||||||
var lastAttemptHost = '';
|
var lastAttemptHost = '';
|
||||||
|
// 本次取址命中的源(成功才写入)——由桥在返回地址前用 android.onSongSource 取走,
|
||||||
|
// 供"只有官方源成功才上报自建服务器"的判定(见 Kotlin SongSourceFlow)。
|
||||||
|
self.lastSourceHost = '';
|
||||||
for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) {
|
for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) {
|
||||||
if (deviceRound > 0) self.regenerateDevice();
|
if (deviceRound > 0) self.regenerateDevice();
|
||||||
// 每一轮使用当时的优先级快照; demote 动态修改 sourceOrder。
|
// 每一轮使用当时的优先级快照; demote 动态修改 sourceOrder。
|
||||||
@@ -492,6 +505,8 @@
|
|||||||
if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) {
|
if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) {
|
||||||
self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host);
|
self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host);
|
||||||
self.promoteSource(host);
|
self.promoteSource(host);
|
||||||
|
// 本次命中的源(Android 侧据此决定是否上报自建服务器)。
|
||||||
|
self.lastSourceHost = host;
|
||||||
hostOk = true;
|
hostOk = true;
|
||||||
// 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。
|
// 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。
|
||||||
reportSongResult(true, host, musicno, 'ok');
|
reportSongResult(true, host, musicno, 'ok');
|
||||||
@@ -532,7 +547,7 @@
|
|||||||
// ─── 导出 ──────────────────────────────────────────
|
// ─── 导出 ──────────────────────────────────────────
|
||||||
global.KtvApi = KtvApi;
|
global.KtvApi = KtvApi;
|
||||||
global.KtvApiConfig = CONFIG;
|
global.KtvApiConfig = CONFIG;
|
||||||
global.KtvBridgeApiVersion = 14;
|
global.KtvBridgeApiVersion = 15;
|
||||||
|
|
||||||
if (typeof module !== 'undefined' && module.exports) {
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
|
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ package com.local.ktv
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 屏蔽/替换后端注入的贴片广告视频。
|
* 屏蔽后端注入的贴片广告视频。
|
||||||
*
|
*
|
||||||
* 后端在下发歌曲播放地址时,可能把广告视频(例如
|
* 后端在下发歌曲播放地址时,可能把广告视频(例如
|
||||||
* `http://gz.ac16.vip/ad_files/my_ad_video.ts`)伪装成歌曲地址返回。
|
* `http://gz.ac16.vip/ad_files/my_ad_video.ts`)伪装成歌曲地址返回。
|
||||||
* 这里统一识别并处理:
|
* 这里统一识别并处理:命中广告特征的地址返回 **null(屏蔽)**,
|
||||||
* - 默认「屏蔽」:广告地址被拦下后,调用方会回落到数据库中的真实歌曲地址
|
* 调用方据此回落到数据库中的真实歌曲地址或重新取址
|
||||||
* (`SongApiClient` 的 DB fallback),从而直接播放歌曲、跳过广告。
|
* (`SongApiClient` 的 DB fallback / `SongOkDownloadManager` 的最终闸门)。
|
||||||
* - 若需要「替换」成自己的视频,把 [replacementUrl] 设置为目标地址即可。
|
*
|
||||||
|
* ⚠️ 2026-10-14 删除了原来那个"替换成自己的视频"的开关 `replacementUrl`:
|
||||||
|
* 全仓搜索(`grep -rn replacementUrl`)**没有任何写入点**,恒为 null,即"替换"这条分支
|
||||||
|
* 从来没有生效过,属于死配置。行为与删除前完全一致(命中广告 → 返回 null)。
|
||||||
*/
|
*/
|
||||||
object AdVideoFilter {
|
object AdVideoFilter {
|
||||||
private const val TAG = "AdVideoFilter"
|
private const val TAG = "AdVideoFilter"
|
||||||
@@ -45,14 +48,6 @@ object AdVideoFilter {
|
|||||||
"ac16.vip",
|
"ac16.vip",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底);
|
|
||||||
* 设置后,命中广告时用该地址替换原广告。
|
|
||||||
*/
|
|
||||||
@Volatile
|
|
||||||
@JvmField
|
|
||||||
var replacementUrl: String? = null
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **媒体地址允许清单**(设置项 `KtvStore.allowedMediaHosts`,默认
|
* **媒体地址允许清单**(设置项 `KtvStore.allowedMediaHosts`,默认
|
||||||
* `md.kasugano.cn` + `192.168.100.232`)。
|
* `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)
|
private val R2_HOST = Regex("^pub-[a-z0-9]+\\.r2\\.dev$", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换);
|
* 处理播放地址:广告地址返回 **null(屏蔽)**,非广告地址原样返回。
|
||||||
* 非广告地址原样返回。
|
*
|
||||||
|
* ⚠️ 原来的「替换」分支(`replacementUrl`)已于 2026-10-14 删除:全仓没有任何写入点,
|
||||||
|
* 恒为 null,即"命中广告→返回替换地址"从未生效。现在只保留屏蔽语义。
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun resolve(url: String?): String? {
|
fun resolve(url: String?): String? {
|
||||||
if (!isAdVideo(url)) return url
|
if (!isAdVideo(url)) return url
|
||||||
if (replacementUrl.isNullOrEmpty()) {
|
Log.w(TAG, "已屏蔽广告视频: $url")
|
||||||
Log.w(TAG, "已屏蔽广告视频: $url")
|
return null
|
||||||
return null
|
|
||||||
}
|
|
||||||
Log.i(TAG, "已替换广告视频: $url -> $replacementUrl")
|
|
||||||
return replacementUrl
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,11 +70,130 @@ class KtvJsBridge(context: Context) {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return 'ok';
|
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<String>, allowedMediaHosts: List<String>): 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>): String =
|
||||||
|
items.joinToString(",", "[", "]") { jsQuote(it) }
|
||||||
|
|
||||||
|
private fun jsQuote(text: String): String =
|
||||||
|
"\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||||
}
|
}
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
private val callbacks = ConcurrentHashMap<String, (String?) -> Unit>()
|
|
||||||
|
/**
|
||||||
|
* 取址回调:`(地址, 产出该地址的源)`。第二项是**显式的来源标志**(由 JS 在返回前
|
||||||
|
* 通过 `android.onSongSource(requestId, host)` 送达),用于汇合点判定"是否上报自建服务器"。
|
||||||
|
*/
|
||||||
|
private val callbacks = ConcurrentHashMap<String, (String?, String?) -> Unit>()
|
||||||
|
|
||||||
|
/** 每个取址请求命中的源(`requestId -> host`),由 `onSongSource` 写入、`complete` 取出。 */
|
||||||
|
private val pendingSourceHosts = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
@Volatile private var webView: WebView? = null
|
@Volatile private var webView: WebView? = null
|
||||||
@Volatile private var ready = false
|
@Volatile private var ready = false
|
||||||
@@ -287,15 +406,15 @@ class KtvJsBridge(context: Context) {
|
|||||||
musicNo: String,
|
musicNo: String,
|
||||||
resolution: String,
|
resolution: String,
|
||||||
h265: Boolean,
|
h265: Boolean,
|
||||||
callback: (String?) -> Unit,
|
callback: (String?, String?) -> Unit,
|
||||||
) {
|
) {
|
||||||
if (!ready || destroyed) {
|
if (!ready || destroyed) {
|
||||||
callback(null)
|
callback(null, null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val requestId = UUID.randomUUID().toString()
|
val requestId = UUID.randomUUID().toString()
|
||||||
callbacks[requestId] = callback
|
callbacks[requestId] = callback
|
||||||
// 源列表 / 媒体地址允许清单**以 App 设置项为准**(自建源优先、官方源兜底):
|
// 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底):
|
||||||
// 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。
|
// 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。
|
||||||
val hosts = sourceHostsForJs()
|
val hosts = sourceHostsForJs()
|
||||||
val allowedHosts = allowedMediaHostsForJs()
|
val allowedHosts = allowedMediaHostsForJs()
|
||||||
@@ -303,10 +422,12 @@ class KtvJsBridge(context: Context) {
|
|||||||
(function() {
|
(function() {
|
||||||
var requestId = ${JSONObject.quote(requestId)};
|
var requestId = ${JSONObject.quote(requestId)};
|
||||||
var completed = false;
|
var completed = false;
|
||||||
function finish(url) {
|
function finish(url, host) {
|
||||||
if (completed) return;
|
if (completed) return;
|
||||||
completed = true;
|
completed = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
// 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。
|
||||||
|
try { android.onSongSource(requestId, host || ''); } catch (e) {}
|
||||||
android.onUrl(requestId, url || '');
|
android.onUrl(requestId, url || '');
|
||||||
}
|
}
|
||||||
var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS);
|
var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS);
|
||||||
@@ -340,10 +461,14 @@ class KtvJsBridge(context: Context) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await window._apiInitPromise;
|
await window._apiInitPromise;
|
||||||
return window.api.getSongUrl(
|
var url = await window.api.getSongUrl(
|
||||||
${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"}
|
${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));
|
android.log('getSongUrl failed: ' + (error && error.message ? error.message : error));
|
||||||
finish('');
|
finish('');
|
||||||
});
|
});
|
||||||
@@ -355,42 +480,30 @@ class KtvJsBridge(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// JS isDemoUrl 已做第一层正则过滤; 这里再做域名白名单兜底, 防止 demo URL 绕过
|
/**
|
||||||
private val trustedHostSuffixes = listOf(
|
* 「设置页改歌曲源/允许清单」后**立即**把新配置注入 JS 会话(原生侧同一时刻已由
|
||||||
"ktvsky.com", "origjoy.com", "cherryonline.cn", "ac16.vip",
|
* [SongUrlResolver.applySourceHosts] 生效)。见 [buildApplySourceSettingsJs]。
|
||||||
"ktvdaren.com", "j-make.cn",
|
*/
|
||||||
)
|
fun applySourceSettingsNow() {
|
||||||
// 已知 demo 域名模式 (Cloudflare R2 测试桶、IP 地址等)
|
val view = webView
|
||||||
private val ipHostPattern = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")
|
if (!ready || destroyed || view == null) return
|
||||||
private val r2HostPattern = Regex("^pub-[a-z0-9]+\\.r2\\.dev$")
|
val script = buildApplySourceSettingsJs(sourceHostsForJs(), allowedMediaHostsForJs())
|
||||||
private val demoHostPatterns = listOf(ipHostPattern, r2HostPattern)
|
mainHandler.post {
|
||||||
|
runCatching { view.evaluateJavascript(script, null) }
|
||||||
private fun isTrustedDownloadUrl(url: String?): Boolean {
|
.onFailure { Log.w(TAG, "apply source settings failed", it) }
|
||||||
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
|
|
||||||
}
|
}
|
||||||
// 媒体地址允许清单(自建源: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?) {
|
private fun complete(requestId: String, url: String?) {
|
||||||
// 先屏蔽/替换广告视频,再做域名白名单校验。
|
// 本次地址来自哪个源:**显式标志**优先,取不到才退回"最近一次 JS 上报的源"。
|
||||||
callbacks.remove(requestId)?.invoke(AdVideoFilter.resolve(url)?.takeIf { isTrustedDownloadUrl(it) })
|
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 =
|
private fun readAssetScript(): String =
|
||||||
@@ -400,7 +513,7 @@ class KtvJsBridge(context: Context) {
|
|||||||
runCatching {
|
runCatching {
|
||||||
file.isFile && file.length() in 1_001..500_000 &&
|
file.isFile && file.length() in 1_001..500_000 &&
|
||||||
file.readText(Charsets.UTF_8).let {
|
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)
|
}.getOrDefault(false)
|
||||||
|
|
||||||
@@ -426,7 +539,7 @@ class KtvJsBridge(context: Context) {
|
|||||||
val script = String(bytes, StandardCharsets.UTF_8)
|
val script = String(bytes, StandardCharsets.UTF_8)
|
||||||
require(
|
require(
|
||||||
script.length > 1000 && script.contains("KtvApi") &&
|
script.length > 1000 && script.contains("KtvApi") &&
|
||||||
script.contains("KTV_BRIDGE_API: 14"),
|
script.contains(BRIDGE_API_MARKER),
|
||||||
) { "Invalid or incompatible JS file" }
|
) { "Invalid or incompatible JS file" }
|
||||||
tempFile.writeText(script, Charsets.UTF_8)
|
tempFile.writeText(script, Charsets.UTF_8)
|
||||||
if (!tempFile.renameTo(cacheFile)) {
|
if (!tempFile.renameTo(cacheFile)) {
|
||||||
@@ -503,6 +616,18 @@ class KtvJsBridge(context: Context) {
|
|||||||
complete(requestId, url)
|
complete(requestId, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JS 在返回地址**之前**回传"本次命中的源"(`requestId -> host`)。
|
||||||
|
*
|
||||||
|
* 这是"是否上报自建服务器"的**显式来源标志**(只有官方源成功才上报),
|
||||||
|
* 与 `onUrl` 分开传是为了兼容旧脚本:老脚本不会调它,
|
||||||
|
* [complete] 会退回 `lastSongSourceHost`(由 `reportSongResult` 维护)。
|
||||||
|
*/
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onSongSource(requestId: String, host: String) {
|
||||||
|
if (host.isNotEmpty()) pendingSourceHosts[requestId] = host
|
||||||
|
}
|
||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun rsaEncryptPkcs1(publicKeyBase64: String, plaintext: String): String =
|
fun rsaEncryptPkcs1(publicKeyBase64: String, plaintext: String): String =
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -562,17 +687,23 @@ class KtvJsBridge(context: Context) {
|
|||||||
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
|
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
|
||||||
// 补发第二次(限流时补发只会双倍放大请求量,加速被源限流)。
|
// 补发第二次(限流时补发只会双倍放大请求量,加速被源限流)。
|
||||||
val raw = rawRequest(method, url, body, headersJson, timeoutMs)
|
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) {
|
if (status > 0) {
|
||||||
|
val responseBody = envelope?.optString("body").orEmpty()
|
||||||
// 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。
|
// 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。
|
||||||
if (status == 403 || status >= 500) {
|
if (status == 403 || status >= 500) {
|
||||||
MiliLog.localError("HTTP-$status $method $url")
|
MiliLog.localError("HTTP-$status $method $url")
|
||||||
}
|
}
|
||||||
// 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为",
|
// 歌曲源试用窗口/风控到期有两种形态:
|
||||||
// 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。
|
// ① HTTP 403;② **HTTP 200 + 业务码 403**(`{"code":403,"msg":"请求被拒绝:检测到异常行为"}`,
|
||||||
// ⚠️ 取 token 的 `/i.php` 同样会 403(2026-10 实测主源 w.w345.my/i.php 就是 403)——
|
// 2026-10-08 实测)。旧实现只认 ①,JS 路径上的风控信号就丢了
|
||||||
// 以前只认 `/music/do.php`,全都 403 时用户看不到任何提示。
|
// (远程日志里只能看到 `do.php code=403`);现在两种都认,
|
||||||
if (status == 403 && (url.contains("/i.php") || url.contains("/music/do.php"))) {
|
// 与原生 `SongUrlResolver.sawForbidden` 口径一致。
|
||||||
|
// ⚠️ 取 token 的 `/i.php` 同样会 403 —— 以前只认 `/music/do.php`。
|
||||||
|
if (riskForbidden(status, responseBody) &&
|
||||||
|
(url.contains("/i.php") || url.contains("/music/do.php"))
|
||||||
|
) {
|
||||||
Eac19WindowGuard.noteRiskBlocked()
|
Eac19WindowGuard.noteRiskBlocked()
|
||||||
}
|
}
|
||||||
return raw
|
return raw
|
||||||
|
|||||||
@@ -68,21 +68,27 @@ class KtvStore {
|
|||||||
* 「App 点歌推送」:把点歌取到的下载地址同步推给自建服务器(`POST <地址>/push_download`,
|
* 「App 点歌推送」:把点歌取到的下载地址同步推给自建服务器(`POST <地址>/push_download`,
|
||||||
* 见 [PushDownload] / [PushDownloadClient] / [SongApiClient.getSongDownloadUrl])。
|
* 见 [PushDownload] / [PushDownloadClient] / [SongApiClient.getSongDownloadUrl])。
|
||||||
*
|
*
|
||||||
* * **留空 = 自动**:若 [sourceHosts] 的第一项是自建源,就用它当推送目标
|
* * **留空 = 自动**:从 [sourceHosts] 里找**第一个自建源**用它当推送目标
|
||||||
* (见 [PushDownload.effectiveServerUrl],2026-10-14 自建源接入后成为默认行为);
|
* (见 [PushDownload.effectiveServerUrl];2026-10-14 起源列表是"官方源优先"、自建源不再排第一,
|
||||||
|
* 所以不再看 `first`);
|
||||||
* * **填 `off` / `none` / `关闭` = 显式关闭**(连请求都不发);
|
* * **填 `off` / `none` / `关闭` = 显式关闭**(连请求都不发);
|
||||||
* * 填其它 `http(s)://…` = 显式覆盖(现有用户完全不受影响)。
|
* * 填其它 `http(s)://…` = 显式覆盖(现有用户完全不受影响)。
|
||||||
|
*
|
||||||
|
* ⚠️ 只有"地址来自官方源"的取址成功才会真的推送(自建源自己给出的地址不上报)。
|
||||||
*/
|
*/
|
||||||
@JvmField var pushServerUrl = ""
|
@JvmField var pushServerUrl = ""
|
||||||
/** 推送鉴权串:必须与服务端的 `push_token` 一致(默认 [DEFAULT_PUSH_TOKEN])。 */
|
/** 推送鉴权串:必须与服务端的 `push_token` 一致(默认 [DEFAULT_PUSH_TOKEN])。 */
|
||||||
@JvmField var pushToken = DEFAULT_PUSH_TOKEN
|
@JvmField var pushToken = DEFAULT_PUSH_TOKEN
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* **歌曲源入口列表**(自建源优先、官方源兜底,默认 = [SourceHosts.DEFAULT])。
|
* **歌曲源入口列表**(官方源优先、自建源兜底,默认 = [SourceHosts.DEFAULT])。
|
||||||
*
|
*
|
||||||
* App 侧([SongUrlResolver])与 JS 侧(`KtvJsBridge` 注入 `CONFIG.HOSTS`)**都以它为准**;
|
* App 侧([SongUrlResolver])与 JS 侧(`KtvJsBridge` 注入 `CONFIG.HOSTS`)**都以它为准**;
|
||||||
* 设置页按"一行一个"编辑,解析/归一化见 [SourceHosts.parse] / [SourceHosts.parseList]
|
* 设置页按"一行一个"编辑,解析/归一化见 [SourceHosts.parse] / [SourceHosts.parseList]
|
||||||
* —— 清空即回落默认列表,绝不会变成"没有源可用"。
|
* —— 清空即回落默认列表,绝不会变成"没有源可用"。
|
||||||
|
*
|
||||||
|
* ⚠️ 1.6.1(已打包、未发布)落盘的"自建源优先"旧默认值会在 [load] 里迁移成新默认
|
||||||
|
* (见 [SourceHosts.migrateLegacyDefault]);用户自定义的列表一律不动。
|
||||||
*/
|
*/
|
||||||
@JvmField val sourceHosts: MutableList<String> = SourceHosts.DEFAULT.toMutableList()
|
@JvmField val sourceHosts: MutableList<String> = SourceHosts.DEFAULT.toMutableList()
|
||||||
|
|
||||||
@@ -153,8 +159,14 @@ class KtvStore {
|
|||||||
pushServerUrl = root.optString("pushServerUrl", "")
|
pushServerUrl = root.optString("pushServerUrl", "")
|
||||||
pushToken = root.optString("pushToken", DEFAULT_PUSH_TOKEN).ifBlank { DEFAULT_PUSH_TOKEN }
|
pushToken = root.optString("pushToken", DEFAULT_PUSH_TOKEN).ifBlank { DEFAULT_PUSH_TOKEN }
|
||||||
// 源列表 / 媒体地址允许清单:空(旧配置没有这两个键)→ 回落默认值。
|
// 源列表 / 媒体地址允许清单:空(旧配置没有这两个键)→ 回落默认值。
|
||||||
|
// ⚠️ 源列表额外做一次"旧默认值 → 新默认值"迁移(1.6.1 落盘的是自建源优先;
|
||||||
|
// 用户自定义的列表不受影响),见 SourceHosts.migrateLegacyDefault。
|
||||||
sourceHosts.clear()
|
sourceHosts.clear()
|
||||||
sourceHosts.addAll(SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts"))))
|
sourceHosts.addAll(
|
||||||
|
SourceHosts.migrateLegacyDefault(
|
||||||
|
SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
allowedMediaHosts.clear()
|
allowedMediaHosts.clear()
|
||||||
allowedMediaHosts.addAll(SourceHosts.parseAllowedList(readStringList(root.optJSONArray("allowedMediaHosts"))))
|
allowedMediaHosts.addAll(SourceHosts.parseAllowedList(readStringList(root.optJSONArray("allowedMediaHosts"))))
|
||||||
readSongs(root.optJSONArray("orderQueue"), orderQueue)
|
readSongs(root.optJSONArray("orderQueue"), orderQueue)
|
||||||
|
|||||||
@@ -140,9 +140,10 @@ class MainActivity : AppCompatActivity() {
|
|||||||
/**
|
/**
|
||||||
* 「切歌 → 公放」专用单线程执行器。
|
* 「切歌 → 公放」专用单线程执行器。
|
||||||
*
|
*
|
||||||
* ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`streamDownloadAndPlay`)这类
|
* ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`startDownloadAndPlay` →
|
||||||
* 几十秒的长任务也在里面。极米 H1S 上并发点几首歌就能把池占满,切歌任务于是一直排队 ——
|
* `downloadSong` → `SongOkDownloadManager`)这类几十秒的长任务也在里面。极米 H1S 上并发点
|
||||||
* 旧歌播完后彻底没声、按什么都没反应(用户报的"老设备卡死",新设备因下载快而看不出来)。
|
* 几首歌就能把池占满,切歌任务于是一直排队 —— 旧歌播完后彻底没声、按什么都没反应
|
||||||
|
* (用户报的"老设备卡死",新设备因下载快而看不出来)。
|
||||||
*/
|
*/
|
||||||
private val randomPlayExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
private val randomPlayExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
||||||
Thread(runnable, "ktv-random-play").apply { isDaemon = true }
|
Thread(runnable, "ktv-random-play").apply { isDaemon = true }
|
||||||
@@ -482,7 +483,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}")
|
Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}")
|
||||||
}
|
}
|
||||||
store.load()
|
store.load()
|
||||||
// 歌曲源列表 / 媒体地址允许清单(自建源优先、官方源兜底):一处装载,
|
// 歌曲源列表 / 媒体地址允许清单(**官方源优先、自建源兜底**):一处装载,
|
||||||
// 原生取址(SongUrlResolver)、地址闸门(AdVideoFilter)与 JS 取址(KtvJsBridge 注入
|
// 原生取址(SongUrlResolver)、地址闸门(AdVideoFilter)与 JS 取址(KtvJsBridge 注入
|
||||||
// CONFIG.HOSTS / CONFIG.ALLOWED_MEDIA_HOSTS)三处都读它。
|
// CONFIG.HOSTS / CONFIG.ALLOWED_MEDIA_HOSTS)三处都读它。
|
||||||
applySourceSettings()
|
applySourceSettings()
|
||||||
@@ -4981,6 +4982,14 @@ class MainActivity : AppCompatActivity() {
|
|||||||
if (currentPage == "已点" || currentPage == "首页") renderQueue()
|
if (currentPage == "已点" || currentPage == "首页") renderQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 备用/历史下载入口(**第五条地址消费点**):直接用曲库条目自带的 `downloadUrl`/`videoUrl`。
|
||||||
|
*
|
||||||
|
* ⚠️ 它**不取址**,所以地址必须自己过闸门([AdVideoFilter.isTrustedSongUrl],与四条取址路径
|
||||||
|
* 同一口径)。历史实现直接下载、完全不过闸门 —— 一旦曲库条目(`Song.remote()` 会填
|
||||||
|
* `downloadUrl`)里带的是广告地址,就会绕过全部防线。不可信一律按「源失效」处理:
|
||||||
|
* 记日志 + 走 [Eac19WindowGuard.noteAllSourcesFailed](「远程源已全部失效,等待新版本修复」)。
|
||||||
|
*/
|
||||||
private fun download(song: Song) {
|
private fun download(song: Song) {
|
||||||
val mainUrl = if (song.downloadUrl == null || song.downloadUrl!!.trim { it <= ' ' }
|
val mainUrl = if (song.downloadUrl == null || song.downloadUrl!!.trim { it <= ' ' }
|
||||||
.isEmpty()) song.videoUrl else song.downloadUrl
|
.isEmpty()) song.videoUrl else song.downloadUrl
|
||||||
@@ -4988,6 +4997,15 @@ class MainActivity : AppCompatActivity() {
|
|||||||
toast("该条目没有下载地址")
|
toast("该条目没有下载地址")
|
||||||
return
|
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)
|
val task = DownloadTask(song)
|
||||||
downloads.add(task)
|
downloads.add(task)
|
||||||
busy(true, "正在下载:" + song.title)
|
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))
|
PushDownload.endpoint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts))
|
||||||
?.let { "已开启:点歌地址推送至 $it" }
|
?.let { "已开启:点歌地址推送至 $it" }
|
||||||
?: "未开启(留空 = 自动用首个自建源;填 off 关闭)",
|
?: "未开启(留空 = 自动用歌曲源里的自建源;填 off 关闭)",
|
||||||
"设置",
|
"设置",
|
||||||
) { showPushDownloadSettings() },
|
) { showPushDownloadSettings() },
|
||||||
SettingsEntry(
|
SettingsEntry(
|
||||||
@@ -9371,9 +9293,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
/**
|
/**
|
||||||
* 「推送下载」设置:把点歌取到的下载地址同步推给自建服务器(服务端 `POST /push_download`)。
|
* 「推送下载」设置:把点歌取到的下载地址同步推给自建服务器(服务端 `POST /push_download`)。
|
||||||
*
|
*
|
||||||
* ⚠️ **留空 = 自动**:首个源是自建源时直接用它当推送目标(见 [PushDownload.effectiveServerUrl]);
|
* ⚠️ **留空 = 自动**:从歌曲源列表里找**自建源**用它当推送目标(见 [PushDownload.effectiveServerUrl];
|
||||||
* 填 `off` / `none` / `关闭` 则关闭该功能(点歌时连请求都不发);填其它地址 = 显式覆盖。
|
* 2026-10-14 起源列表是"官方源优先",自建源不再排第一);填 `off` / `none` / `关闭` 则关闭该功能
|
||||||
|
* (点歌时连请求都不发);填其它地址 = 显式覆盖。
|
||||||
* 改完立刻生效(推送时现读 [store]),不需要重启。
|
* 改完立刻生效(推送时现读 [store]),不需要重启。
|
||||||
|
*
|
||||||
|
* ⚠️ 只有"地址来自官方源"的取址成功才会真的推送(自建源取到的地址不上报)。
|
||||||
*/
|
*/
|
||||||
private fun showPushDownloadSettings() {
|
private fun showPushDownloadSettings() {
|
||||||
val box = LinearLayout(this)
|
val box = LinearLayout(this)
|
||||||
@@ -9383,12 +9308,12 @@ class MainActivity : AppCompatActivity() {
|
|||||||
urlInput.setSingleLine(true)
|
urlInput.setSingleLine(true)
|
||||||
urlInput.setText(store.pushServerUrl)
|
urlInput.setText(store.pushServerUrl)
|
||||||
urlInput.setHint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
|
urlInput.setHint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
|
||||||
.ifEmpty { SourceHosts.DEFAULT.first() } + "(留空 = 自动)")
|
.ifEmpty { "歌曲源里没有自建源" } + "(留空 = 自动)")
|
||||||
val tokenInput = EditText(this)
|
val tokenInput = EditText(this)
|
||||||
tokenInput.setSingleLine(true)
|
tokenInput.setSingleLine(true)
|
||||||
tokenInput.setText(store.pushToken)
|
tokenInput.setText(store.pushToken)
|
||||||
tokenInput.setHint(KtvStore.DEFAULT_PUSH_TOKEN)
|
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(urlInput, LinearLayout.LayoutParams(-1, dp(58)))
|
||||||
box.addView(label("鉴权串(需与服务端 push_token 一致,默认 local)", 14, Color.WHITE))
|
box.addView(label("鉴权串(需与服务端 push_token 一致,默认 local)", 14, Color.WHITE))
|
||||||
box.addView(tokenInput, LinearLayout.LayoutParams(-1, dp(58)))
|
box.addView(tokenInput, LinearLayout.LayoutParams(-1, dp(58)))
|
||||||
@@ -9410,19 +9335,26 @@ class MainActivity : AppCompatActivity() {
|
|||||||
/**
|
/**
|
||||||
* 把「歌曲源 / 媒体地址允许清单」设置应用到两份取址实现与地址闸门。
|
* 把「歌曲源 / 媒体地址允许清单」设置应用到两份取址实现与地址闸门。
|
||||||
*
|
*
|
||||||
* 一处装载、三处生效:原生取址([SongUrlResolver.applySourceHosts])、地址闸门
|
* **四处生效**:原生取址([SongUrlResolver.applySourceHosts])、地址闸门
|
||||||
* ([AdVideoFilter.setAllowedMediaHosts])、JS 取址([KtvJsBridge] 每次取址读同一份设置注入
|
* ([AdVideoFilter.setAllowedMediaHosts])、JS 取址的下一次构造
|
||||||
* `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`)。启动与设置页保存后各调一次即可。
|
* ([KtvJsBridge] 每次取址读同一份设置注入 `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`),
|
||||||
|
* 以及**当前已构造的 JS 会话**([SongApiClient.applySourceSettings] → 重新注入
|
||||||
|
* `KtvApiConfig.*` 并重置 `window.api.sourceOrder`)。
|
||||||
|
*
|
||||||
|
* ⚠️ 最后一项是隐患 1 的修复:只写下面前两行时,原生侧**立刻**换源,
|
||||||
|
* 而 JS 主路径仍按构造时的旧 `sourceOrder` 选源 —— 两条路径会在同一时刻用不同的源顺序。
|
||||||
*/
|
*/
|
||||||
private fun applySourceSettings() {
|
private fun applySourceSettings() {
|
||||||
SongUrlResolver.applySourceHosts(store.sourceHosts)
|
SongUrlResolver.applySourceHosts(store.sourceHosts)
|
||||||
AdVideoFilter.setAllowedMediaHosts(store.allowedMediaHosts)
|
AdVideoFilter.setAllowedMediaHosts(store.allowedMediaHosts)
|
||||||
|
SongApiClient.applySourceSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「歌曲源」设置:**自建源优先、官方源兜底**,一行一个。
|
* 「歌曲源」设置:**官方源优先、自建源兜底**,一行一个。
|
||||||
*
|
*
|
||||||
* 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`)。
|
* 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`),
|
||||||
|
* 保存后**立即**对两条路径生效(见 [applySourceSettings])。
|
||||||
*/
|
*/
|
||||||
private fun showSourceHostsDialog() {
|
private fun showSourceHostsDialog() {
|
||||||
val input = EditText(this).apply {
|
val input = EditText(this).apply {
|
||||||
@@ -9441,9 +9373,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
label("只认 http(s):// 开头;留空 = 回落默认:\n" + SourceHosts.DEFAULT.joinToString("\n"), 13, Color.LTGRAY),
|
label("只认 http(s):// 开头;留空 = 回落默认:\n" + SourceHosts.DEFAULT.joinToString("\n"), 13, Color.LTGRAY),
|
||||||
)
|
)
|
||||||
AlertDialog.Builder(this)
|
AlertDialog.Builder(this)
|
||||||
.setTitle("歌曲源(自建源优先、官方源兜底)")
|
.setTitle("歌曲源(官方源优先、自建源兜底)")
|
||||||
.setView(box)
|
.setView(box)
|
||||||
.setMessage("第一个源同时是「推送下载」的默认目标。清空即恢复默认列表。")
|
.setMessage("官方源成功后会把地址上报自建源(POST /push_download);自建源自己取到的地址不上报。清空即恢复默认列表。")
|
||||||
.setNegativeButton("取消", null)
|
.setNegativeButton("取消", null)
|
||||||
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
|
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
|
||||||
store.applySourceHostsText(input.text.toString())
|
store.applySourceHostsText(input.text.toString())
|
||||||
|
|||||||
@@ -16,9 +16,12 @@ import java.util.LinkedHashMap
|
|||||||
* 端点拼接 / body 拼接 / 5 分钟去重 / "配置为空就绝不调用发送器" —— 全是纯函数;
|
* 端点拼接 / body 拼接 / 5 分钟去重 / "配置为空就绝不调用发送器" —— 全是纯函数;
|
||||||
* 真正发请求(后台线程 + 8 秒超时 + HttpURLConnection)在 [PushDownloadClient]。
|
* 真正发请求(后台线程 + 8 秒超时 + HttpURLConnection)在 [PushDownloadClient]。
|
||||||
*
|
*
|
||||||
* ⚠️ **默认目标**(2026-10-14 自建源接入后):`pushServerUrl` **留空 = 自动** —— 首源是自建源时
|
* ⚠️ **默认目标**(2026-10-14):`pushServerUrl` **留空 = 自动** —— 从歌曲源列表里找**自建源**
|
||||||
* 就用它当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。
|
* 当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。
|
||||||
* 关闭时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。
|
* 关闭时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。
|
||||||
|
*
|
||||||
|
* ⚠️ 触发条件(2026-10-14 起):只有"地址来自**官方源**"的取址成功才推送
|
||||||
|
* (见 [SourcedSongUrl.fromOfficialSource] / `SongApiClient.getSongDownloadUrl`)。
|
||||||
*/
|
*/
|
||||||
object PushDownload {
|
object PushDownload {
|
||||||
/** 与服务端 `push_token` 的默认值保持一致。 */
|
/** 与服务端 `push_token` 的默认值保持一致。 */
|
||||||
@@ -36,7 +39,7 @@ object PushDownload {
|
|||||||
/**
|
/**
|
||||||
* 显式"关闭推送"的取值(见 [effectiveServerUrl])。
|
* 显式"关闭推送"的取值(见 [effectiveServerUrl])。
|
||||||
*
|
*
|
||||||
* 为什么需要它:`pushServerUrl` **留空 = 自动**(首源是自建源时就用它当推送目标),
|
* 为什么需要它:`pushServerUrl` **留空 = 自动**(从歌曲源列表里找自建源当推送目标),
|
||||||
* 所以"清空关闭"必须有一个明确写法,否则用户没法把自动默认关掉。
|
* 所以"清空关闭"必须有一个明确写法,否则用户没法把自动默认关掉。
|
||||||
*/
|
*/
|
||||||
private val DISABLED_VALUES = setOf("off", "none", "no", "0", "false", "关闭", "禁用")
|
private val DISABLED_VALUES = setOf("off", "none", "no", "0", "false", "关闭", "禁用")
|
||||||
@@ -49,8 +52,12 @@ object PushDownload {
|
|||||||
*
|
*
|
||||||
* 1. 显式配置优先:非空且不是 [DISABLED_VALUES] → 原样使用(**现有用户完全不受影响**);
|
* 1. 显式配置优先:非空且不是 [DISABLED_VALUES] → 原样使用(**现有用户完全不受影响**);
|
||||||
* 2. 显式关闭(`off`/`none`/`关闭`…)→ 返回空串 = 关闭;
|
* 2. 显式关闭(`off`/`none`/`关闭`…)→ 返回空串 = 关闭;
|
||||||
* 3. 留空 → 若 `sourceHosts` 的**第一项是自建源**([SourceHosts.isSelfHost])就用它,
|
* 3. 留空 → 取 `sourceHosts` 里**第一个自建源**([SourceHosts.isSelfHost])就用它,
|
||||||
* 否则仍关闭(官方源不能当推送目标)。
|
* 一个自建源都没有 → 关闭(官方源不能当推送目标)。
|
||||||
|
*
|
||||||
|
* ⚠️ 2026-10-14 起源列表改为「官方源优先、自建源兜底」,自建源**不一定排在第一位** ——
|
||||||
|
* 所以这里改为"**按列表找第一个自建源**"(旧写法只看 `first`,新默认顺序下会直接把推送关掉,
|
||||||
|
* 而"官方源成功就上报"正是这一版的核心需求)。
|
||||||
*
|
*
|
||||||
* @return 空串 = 关闭;调用方再交给 [endpoint] 判合法性(非 http(s) 一律按关闭处理)。
|
* @return 空串 = 关闭;调用方再交给 [endpoint] 判合法性(非 http(s) 一律按关闭处理)。
|
||||||
*/
|
*/
|
||||||
@@ -60,8 +67,8 @@ object PushDownload {
|
|||||||
if (raw.isNotEmpty()) {
|
if (raw.isNotEmpty()) {
|
||||||
return if (raw.lowercase() in DISABLED_VALUES) "" else raw
|
return if (raw.lowercase() in DISABLED_VALUES) "" else raw
|
||||||
}
|
}
|
||||||
val first = sourceHosts?.firstOrNull()?.trim().orEmpty()
|
val self = sourceHosts?.firstOrNull { SourceHosts.isSelfHost(it) }?.trim().orEmpty()
|
||||||
return if (SourceHosts.isSelfHost(first)) first else ""
|
return if (SourceHosts.isSelfHost(self)) self else ""
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import java.net.URL
|
|||||||
/**
|
/**
|
||||||
* 「App 点歌推送」的 **Android 侧胶水**:读设置 → 后台线程 → 8 秒超时 → fire-and-forget。
|
* 「App 点歌推送」的 **Android 侧胶水**:读设置 → 后台线程 → 8 秒超时 → fire-and-forget。
|
||||||
*
|
*
|
||||||
* ## 切入点
|
* ## 切入点与触发条件
|
||||||
* 由 [SongApiClient.getSongDownloadUrl] 在**拿到下载地址之后**调用(那是 JS / 原生 / DB 回退
|
* 由 [SongApiClient.getSongDownloadUrl] 在**拿到下载地址之后**调用(那是 JS / 原生 / DB 回退
|
||||||
* 四条取址路径唯一的汇合点)。本类的每一步都**不能影响点歌**:
|
* 四条取址路径唯一的汇合点),并且**只有地址来自官方源时才调用**
|
||||||
* * 目标默认:`pushServerUrl` 留空时用**首个自建源**(见 [PushDownload.effectiveServerUrl]);
|
* (见 [SongSourceFlow.shouldReportToSelfHost])。本类的每一步都**不能影响点歌**:
|
||||||
* 显式 `off`/`none`/`关闭` 或首源不是自建源时 [PushDownload.endpoint] 为 null
|
* * 目标默认:`pushServerUrl` 留空时用歌曲源列表里的**自建源**(见 [PushDownload.effectiveServerUrl]);
|
||||||
|
* 显式 `off`/`none`/`关闭` 或列表里没有自建源时 [PushDownload.endpoint] 为 null
|
||||||
* → **连线程都不起、绝不发请求**;
|
* → **连线程都不起、绝不发请求**;
|
||||||
* * 5 分钟内存去重([PushDownload.shouldSkip]):同一首歌反复点/重复取址只推一次;
|
* * 5 分钟内存去重([PushDownload.shouldSkip]):同一首歌反复点/重复取址只推一次;
|
||||||
* * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示);
|
* * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示);
|
||||||
@@ -47,7 +48,7 @@ object PushDownloadClient {
|
|||||||
private fun pushNow(tid: Long, url: String, name: String) {
|
private fun pushNow(tid: Long, url: String, name: String) {
|
||||||
val store = KtvStore()
|
val store = KtvStore()
|
||||||
store.load()
|
store.load()
|
||||||
// 目标:显式配置优先;留空且首源是自建源 → 默认用它(2026-10-14 起的行为)。
|
// 目标:显式配置优先;留空 → 用歌曲源列表里的自建源(2026-10-14 起的行为)。
|
||||||
val serverUrl = PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
|
val serverUrl = PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
|
||||||
if (PushDownload.endpoint(serverUrl) == null) return // 关闭:不发请求(连 POST 都不构造)
|
if (PushDownload.endpoint(serverUrl) == null) return // 关闭:不发请求(连 POST 都不构造)
|
||||||
val host = SourceHosts.hostOf(serverUrl)
|
val host = SourceHosts.hostOf(serverUrl)
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ object SongApiClient {
|
|||||||
* (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了
|
* (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了
|
||||||
* 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。
|
* 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。
|
||||||
*
|
*
|
||||||
|
* ⚠️ **只有"地址来自官方源"才推送**(2026-10-14 新流程):官方源成功 → 把该下载地址
|
||||||
|
* 上报自建服务器(`POST /push_download`,让它留存并入库这首);地址来自**自建源**时不推
|
||||||
|
* (服务器本来就有这首),DB 兜底同样不推。判定用的是取址实现**显式返回**的来源标志
|
||||||
|
* ([SourcedSongUrl.sourceHost],见 [SongSourceFlow.shouldReportToSelfHost]),
|
||||||
|
* **不靠 URL 字符串猜、也不读可变全局**。
|
||||||
|
*
|
||||||
* 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、
|
* 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、
|
||||||
* 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。
|
* 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。
|
||||||
*
|
*
|
||||||
@@ -70,16 +76,25 @@ object SongApiClient {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? {
|
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? {
|
||||||
val url = resolveSongDownloadUrl(musicno, resolution, h265)
|
val resolved = resolveSongDownloadUrl(musicno, resolution, h265)
|
||||||
if (!url.isNullOrEmpty() && musicno.isNotBlank()) {
|
val url = resolved?.url
|
||||||
|
if (!url.isNullOrEmpty() && musicno.isNotBlank() &&
|
||||||
|
SongSourceFlow.shouldReportToSelfHost(resolved)
|
||||||
|
) {
|
||||||
runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) }
|
runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) }
|
||||||
.onFailure { Log.w(TAG, "push download skipped", it) }
|
.onFailure { Log.w(TAG, "push download skipped", it) }
|
||||||
}
|
}
|
||||||
return url
|
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` 也要求非空),否则会拿空编号去扫一遍上游源
|
// 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源
|
||||||
if (musicno.isBlank()) return null
|
if (musicno.isBlank()) return null
|
||||||
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
||||||
@@ -90,11 +105,18 @@ object SongApiClient {
|
|||||||
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
|
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
|
||||||
return fetchFromDb(musicno)
|
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 秒,直接用原生通道。
|
// 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。
|
||||||
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
|
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
|
||||||
if (bridge.isUnavailable()) {
|
if (bridge.isUnavailable()) {
|
||||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
return SongSourceFlow.firstAvailable(
|
||||||
|
{ resolveNatively(musicno, resolution, h265) },
|
||||||
|
{ fetchFromDb(musicno) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
||||||
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
||||||
@@ -112,11 +134,18 @@ object SongApiClient {
|
|||||||
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
|
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
|
||||||
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
||||||
bridge.markUnavailable("15s 未就绪")
|
bridge.markUnavailable("15s 未就绪")
|
||||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
return SongSourceFlow.firstAvailable(
|
||||||
|
{ resolveNatively(musicno, resolution, h265) },
|
||||||
|
{ fetchFromDb(musicno) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
val result = AtomicReference<String?>()
|
val result = AtomicReference<SourcedSongUrl?>()
|
||||||
val latch = CountDownLatch(1)
|
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 秒总超时。
|
// JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。
|
||||||
try {
|
try {
|
||||||
latch.await(185, TimeUnit.SECONDS)
|
latch.await(185, TimeUnit.SECONDS)
|
||||||
@@ -125,37 +154,60 @@ object SongApiClient {
|
|||||||
}
|
}
|
||||||
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
|
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
|
||||||
// 避免 JS 侧偶发失败直接把第一首歌判死。
|
// 避免 JS 侧偶发失败直接把第一首歌判死。
|
||||||
val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
val resolved = SongSourceFlow.firstAvailable(
|
||||||
if (url != null) {
|
{ result.get() },
|
||||||
|
{ resolveNatively(musicno, resolution, h265) },
|
||||||
|
{ fetchFromDb(musicno) },
|
||||||
|
)
|
||||||
|
if (resolved != null) {
|
||||||
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
|
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
|
||||||
Eac19WindowGuard.noteUrlOk()
|
Eac19WindowGuard.noteUrlOk()
|
||||||
}
|
}
|
||||||
return url
|
return resolved
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
|
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
|
||||||
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
|
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
|
||||||
*/
|
*/
|
||||||
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? =
|
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? =
|
||||||
runCatching { SongUrlResolver.resolve(musicno, resolution, h265) }
|
runCatching { SongUrlResolver.resolveSourced(musicno, resolution, h265) }
|
||||||
.onFailure { Log.w(TAG, "native resolver failed", it) }
|
.onFailure { Log.w(TAG, "native resolver failed", it) }
|
||||||
.getOrNull()
|
.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()
|
val db = MuseDatabase()
|
||||||
if (db.open()) {
|
if (!db.open()) {
|
||||||
val url = db.getCloudUrl(musicno)
|
MiliLog.localWarn("SONG-DB 曲库未就绪,跳过 cloud_url 回落 编号=$musicno")
|
||||||
db.close()
|
return null
|
||||||
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
|
|
||||||
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
|
|
||||||
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {}
|
val url = db.getCloudUrl(musicno)
|
||||||
return null
|
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()
|
bridge?.resetApiSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置页改了「歌曲源 / 媒体地址允许清单」后**立即**把新配置注入 JS 会话(隐患 1)。
|
||||||
|
*
|
||||||
|
* 原生侧由 [SongUrlResolver.applySourceHosts] 立刻生效;JS 侧过去只在下一次"重建会话"
|
||||||
|
* (`window.api == null`)时才拿到新列表,已构造的 `window.api.sourceOrder` 仍是旧顺序 ——
|
||||||
|
* 两条取址路径会在同一时刻用**不同的源顺序**。现在保存设置后直接重新注入
|
||||||
|
* (`KtvApiConfig.*` + `window.api.sourceOrder`),两端立刻一致。
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun applySourceSettings() {
|
||||||
|
bridge?.applySourceSettingsNow()
|
||||||
|
}
|
||||||
|
|
||||||
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
|
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun lastSongSourceHost(): String =
|
fun lastSongSourceHost(): String =
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,19 +50,15 @@ object SongUrlResolver {
|
|||||||
private const val CONFIG_TTL_MS = 30 * 60_000L
|
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`,
|
* ⚠️ **单一来源**:直接引用 [SourceHosts.DEFAULT](不再各写一份字面量)。
|
||||||
* 直连内网时是 `http://192.168.100.232:6677`,媒体地址为 `/media/<编号>.ts`);
|
* 另一处字面量在 `assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(JS 无法引用 Kotlin 常量),
|
||||||
* * `http://zk.ktv1.top:6677` —— 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。
|
* 由 [`SourceHostsTest`] 与 `tools/check_source_hosts.py` 锁住一致性。
|
||||||
*
|
*
|
||||||
* ⚠️ 与 `assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(及 `CONFIG.HOST`)**逐项一致**;
|
|
||||||
* 运行时可被设置项 `KtvStore.sourceHosts` 覆盖(见 [applySourceHosts]),两份实现共读同一份设置。
|
* 运行时可被设置项 `KtvStore.sourceHosts` 覆盖(见 [applySourceHosts]),两份实现共读同一份设置。
|
||||||
*/
|
*/
|
||||||
private val DEFAULT_HOSTS = listOf(
|
private val DEFAULT_HOSTS = SourceHosts.DEFAULT
|
||||||
"https://mdmuse.kasugano.cn",
|
|
||||||
"http://zk.ktv1.top:6677",
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
|
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
|
||||||
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
|
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
|
||||||
@@ -150,6 +146,15 @@ object SongUrlResolver {
|
|||||||
/** 当前运行时源列表(只读快照,供日志/自检)。 */
|
/** 当前运行时源列表(只读快照,供日志/自检)。 */
|
||||||
fun currentHosts(): List<String> = synchronized(hostOrder) { hostOrder.toList() }
|
fun currentHosts(): List<String> = synchronized(hostOrder) { hostOrder.toList() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **默认**源列表(不受运行时 promote/demote 与设置项影响)。
|
||||||
|
*
|
||||||
|
* 就是 [SourceHosts.DEFAULT](单一来源),单独暴露是为了让"源顺序三处一致"的自检/单测
|
||||||
|
* ([SourceHostsTest] 与 `tools/check_source_hosts.py`、JS 的 `CONFIG.HOSTS`)能直接读它。
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun defaultHosts(): List<String> = DEFAULT_HOSTS
|
||||||
|
|
||||||
private fun demote(host: String) {
|
private fun demote(host: String) {
|
||||||
synchronized(hostOrder) {
|
synchronized(hostOrder) {
|
||||||
val index = hostOrder.indexOf(host)
|
val index = hostOrder.indexOf(host)
|
||||||
@@ -214,8 +219,18 @@ object SongUrlResolver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
|
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null([resolveSourced] 的字符串版)。 */
|
||||||
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
|
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
|
if (musicno.isBlank()) return null
|
||||||
val channel = fetch ?: run {
|
val channel = fetch ?: run {
|
||||||
MiliLog.localWarn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
|
MiliLog.localWarn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
|
||||||
@@ -273,7 +288,7 @@ object SongUrlResolver {
|
|||||||
"NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " +
|
"NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " +
|
||||||
"第${index + 1}个源 用时=${elapsed}ms",
|
"第${index + 1}个源 用时=${elapsed}ms",
|
||||||
)
|
)
|
||||||
return url
|
return SourcedSongUrl(url, host)
|
||||||
}
|
}
|
||||||
lastDetail = when {
|
lastDetail = when {
|
||||||
url.isNullOrEmpty() -> "no-url"
|
url.isNullOrEmpty() -> "no-url"
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import java.util.LinkedHashSet
|
|||||||
* 两边的**默认值必须逐项一致**、且都要能被设置项覆盖(见 [KtvStore.sourceHosts])。
|
* 两边的**默认值必须逐项一致**、且都要能被设置项覆盖(见 [KtvStore.sourceHosts])。
|
||||||
* 解析/归一化这类"没有 IO 的判断"全部收敛到这里,JS 侧只负责"接收注入的数组"。
|
* 解析/归一化这类"没有 IO 的判断"全部收敛到这里,JS 侧只负责"接收注入的数组"。
|
||||||
*
|
*
|
||||||
* ## 2026-10-14 自建源上线后的默认值
|
* ## 2026-10-14 取址改为「官方源优先、自建源兜底」
|
||||||
* 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**排在第一位**,
|
* 官方源 `http://zk.ktv1.top:6677` **排在第一位**(成功率与稳定性最好的入口),
|
||||||
* 官方源 `http://zk.ktv1.top:6677` 兜底 —— 自建源取不到地址时仍能回落到官方源,不影响老用户。
|
* 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**兜底** ——
|
||||||
|
* 官方源取不到地址时仍能回落,不影响老用户。
|
||||||
|
* 只有"地址来自官方源"才把该地址上报给自建服务器(见 [SourcedSongUrl.fromOfficialSource]
|
||||||
|
* 与 `SongApiClient.getSongDownloadUrl`);自建源自己给出的地址**不上报**。
|
||||||
*
|
*
|
||||||
* ⚠️ 允许清单([DEFAULT_ALLOWED_MEDIA_HOSTS])只放行"**必须是域名**"这一条限制
|
* ⚠️ 允许清单([DEFAULT_ALLOWED_MEDIA_HOSTS])只放行"**必须是域名**"这一条限制
|
||||||
* (直连内网自建源时媒体地址就是裸 IP:`http://192.168.100.232:6677/media/<编号>.ts`);
|
* (直连内网自建源时媒体地址就是裸 IP:`http://192.168.100.232:6677/media/<编号>.ts`);
|
||||||
@@ -22,13 +25,27 @@ import java.util.LinkedHashSet
|
|||||||
object SourceHosts {
|
object SourceHosts {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认源入口列表:**自建源优先、官方源兜底**。
|
* 默认源入口列表:**官方源优先、自建源兜底**。
|
||||||
*
|
*
|
||||||
* ⚠️ 与 `app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(以及 `CONFIG.HOST`)
|
* ⚠️ 与 `app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(以及 `CONFIG.HOST`)
|
||||||
* **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本)。
|
* **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本
|
||||||
|
* `tools/check_source_hosts.py`)。`SongUrlResolver.DEFAULT_HOSTS` 已收敛为**引用本常量**
|
||||||
|
* (不再各写一份),三处实际只剩两处字面量。
|
||||||
*/
|
*/
|
||||||
@JvmField
|
@JvmField
|
||||||
val DEFAULT = listOf(
|
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",
|
"https://mdmuse.kasugano.cn",
|
||||||
"http://zk.ktv1.top:6677",
|
"http://zk.ktv1.top:6677",
|
||||||
)
|
)
|
||||||
@@ -84,6 +101,18 @@ object SourceHosts {
|
|||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun parseAllowed(text: String?): List<String> = parseAllowedList(text?.split(SEPARATORS))
|
fun parseAllowed(text: String?): List<String> = parseAllowedList(text?.split(SEPARATORS))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把**已落盘**的源列表迁移到当前默认顺序:
|
||||||
|
* 只有"恰好等于 [LEGACY_DEFAULT](1.6.1 的旧默认)"时才换成 [DEFAULT],
|
||||||
|
* 其余(含用户自定义、含空 → 已由 [parseList] 回落默认)原样返回。
|
||||||
|
*
|
||||||
|
* 为什么需要它:1.6.1 已打包(虽然未发布)并可能已装到测试设备上,
|
||||||
|
* 其 `state.json` 里存着"自建源优先"的顺序 —— 不迁移的话新默认值在这些设备上不生效。
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun migrateLegacyDefault(hosts: List<String>?): List<String> =
|
||||||
|
if (hosts == LEGACY_DEFAULT) DEFAULT else hosts ?: DEFAULT
|
||||||
|
|
||||||
/** 归一化整份允许清单;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS]。 */
|
/** 归一化整份允许清单;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS]。 */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun parseAllowedList(items: Collection<String>?): List<String> {
|
fun parseAllowedList(items: Collection<String>?): List<String> {
|
||||||
@@ -110,14 +139,37 @@ object SourceHosts {
|
|||||||
return host.trim().lowercase().ifEmpty { null }
|
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/端口无关):
|
* 该源入口是不是**自建源**(只看主机名,与 scheme/端口无关):
|
||||||
* 公网 API 站 `mdmuse.kasugano.cn` 与内网直连 `192.168.100.232` 都是同一个服务端。
|
* 公网 API 站 `mdmuse.kasugano.cn` 与内网直连 `192.168.100.232` 都是同一个服务端。
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun isSelfHost(url: String?): Boolean {
|
fun isSelfHost(url: String?): Boolean = isSelfHostName(url)
|
||||||
val host = hostOf(url) ?: return false
|
|
||||||
return host == SELF_HOST || host == SELF_HOST_LAN
|
/**
|
||||||
|
* 该源入口/主机名是不是**官方源**(=非自建源的源)——决定"成功取址后是否上报自建服务器"。
|
||||||
|
*
|
||||||
|
* 需求:**只有地址来自官方源时才上报**(`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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -144,4 +144,19 @@ public final class AdVideoFilterTest {
|
|||||||
assertNull(AdVideoFilter.tsBasename(
|
assertNull(AdVideoFilter.tsBasename(
|
||||||
AdVideoFilter.pathOf("http://txog.ktvsky.com/a/stream?token=x")));
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <name>(...)` 的函数体(到下一个同级 `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 异常"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,27 +13,30 @@ import java.util.List;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 歌曲源列表 / 媒体地址允许清单 / 推送默认目标的纯 JVM 单测。
|
* 歌曲源列表 / 媒体地址允许清单 / 推送默认目标 / 旧默认值迁移的纯 JVM 单测。
|
||||||
*
|
*
|
||||||
* 覆盖需求点名的两组:
|
* 覆盖需求点名的几组:
|
||||||
* ② 源列表解析(`sourceHosts` 为空时回落默认;含自建源优先);
|
* ② 源列表解析(`sourceHosts` 为空时回落默认;**官方源优先、自建源兜底**);
|
||||||
* ④ 推送默认目标(`pushServerUrl` 为空且首源为自建源时使用它,否则尊重显式配置)。
|
* ④ 推送默认目标(`pushServerUrl` 为空时从列表里找自建源,找不到才关闭);
|
||||||
|
* ③ 1.6.1 旧默认顺序(自建源优先)落盘值的一次性迁移。
|
||||||
*/
|
*/
|
||||||
public class SourceHostsTest {
|
public class SourceHostsTest {
|
||||||
|
|
||||||
// ── ② 源列表解析 ──
|
// ── ② 源列表解析 ──
|
||||||
|
|
||||||
/** 空(旧配置没有这个键 / 用户清空)→ 回落默认:**自建源第一、官方源兜底**。 */
|
/** 空(旧配置没有这个键 / 用户清空)→ 回落默认:**官方源第一、自建源兜底**。 */
|
||||||
@Test
|
@Test
|
||||||
public void emptySourceListFallsBackToDefaultWithSelfHostFirst() {
|
public void emptySourceListFallsBackToDefaultWithOfficialFirst() {
|
||||||
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(null));
|
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(null));
|
||||||
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(""));
|
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(""));
|
||||||
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(" \n \n"));
|
assertEquals(SourceHosts.DEFAULT, SourceHosts.parse(" \n \n"));
|
||||||
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(null));
|
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(null));
|
||||||
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(Collections.<String>emptyList()));
|
assertEquals(SourceHosts.DEFAULT, SourceHosts.parseList(Collections.<String>emptyList()));
|
||||||
assertEquals("https://mdmuse.kasugano.cn", SourceHosts.DEFAULT.get(0));
|
assertEquals("http://zk.ktv1.top:6677", SourceHosts.DEFAULT.get(0));
|
||||||
assertEquals("http://zk.ktv1.top:6677", SourceHosts.DEFAULT.get(1));
|
assertEquals("https://mdmuse.kasugano.cn", SourceHosts.DEFAULT.get(1));
|
||||||
assertTrue("默认列表里自建源必须排第一", SourceHosts.isSelfHost(SourceHosts.DEFAULT.get(0)));
|
assertTrue("默认列表第一项必须是官方源", SourceHosts.isOfficialSource(SourceHosts.DEFAULT.get(0)));
|
||||||
|
assertFalse("默认列表第二项是自建源兜底", SourceHosts.isOfficialSource(SourceHosts.DEFAULT.get(1)));
|
||||||
|
assertTrue(SourceHosts.isSelfHost(SourceHosts.DEFAULT.get(1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置页"一行一个":去空白/尾斜杠、按顺序去重、忽略非 http(s) 的错行。 */
|
/** 设置页"一行一个":去空白/尾斜杠、按顺序去重、忽略非 http(s) 的错行。 */
|
||||||
@@ -53,18 +56,47 @@ public class SourceHostsTest {
|
|||||||
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/原生)都读这个顺序。 */
|
/** 官方源优先:官方源放第一 + 自建源兜底,两份实现(JS/原生)都读这个顺序。 */
|
||||||
@Test
|
@Test
|
||||||
public void selfHostFirstThenOfficialFallback() {
|
public void officialFirstThenSelfHostFallback() {
|
||||||
List<String> hosts = SourceHosts.parseList(Arrays.asList(
|
List<String> hosts = SourceHosts.parseList(Arrays.asList(
|
||||||
"https://mdmuse.kasugano.cn",
|
"http://zk.ktv1.top:6677",
|
||||||
"http://zk.ktv1.top:6677"));
|
"https://mdmuse.kasugano.cn"));
|
||||||
assertTrue(SourceHosts.isSelfHost(hosts.get(0)));
|
assertTrue(SourceHosts.isOfficialSource(hosts.get(0)));
|
||||||
assertFalse(SourceHosts.isSelfHost(hosts.get(1)));
|
assertFalse(SourceHosts.isOfficialSource(hosts.get(1)));
|
||||||
|
assertTrue(SourceHosts.isSelfHost(hosts.get(1)));
|
||||||
// 主机名判定与 scheme/端口无关(内网直连也是自建源)
|
// 主机名判定与 scheme/端口无关(内网直连也是自建源)
|
||||||
assertTrue(SourceHosts.isSelfHost("http://mdmuse.kasugano.cn:6677"));
|
assertTrue(SourceHosts.isSelfHost("http://mdmuse.kasugano.cn:6677"));
|
||||||
|
assertTrue(SourceHosts.isSelfHostName("192.168.100.232"));
|
||||||
assertFalse(SourceHosts.isSelfHost(null));
|
assertFalse(SourceHosts.isSelfHost(null));
|
||||||
assertFalse(SourceHosts.isSelfHost(""));
|
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<String> legacy = new ArrayList<String>(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<String> custom = Arrays.asList("http://192.168.100.232:6677", "http://zk.ktv1.top:6677");
|
||||||
|
assertEquals(custom, SourceHosts.migrateLegacyDefault(custom));
|
||||||
|
List<String> 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
|
@Test
|
||||||
public void pushTargetAutoUsesFirstSelfHostSource() {
|
public void pushTargetAutoUsesSelfHostAnywhereInList() {
|
||||||
// 留空 + 首源是自建源 → 用它(自建源接入后的默认行为)
|
|
||||||
assertEquals("https://mdmuse.kasugano.cn",
|
assertEquals("https://mdmuse.kasugano.cn",
|
||||||
PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT));
|
PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT));
|
||||||
assertEquals("http://192.168.100.232:6677",
|
assertEquals("http://192.168.100.232:6677",
|
||||||
@@ -95,6 +127,11 @@ public class SourceHostsTest {
|
|||||||
// 空配置也照样能拼出端点
|
// 空配置也照样能拼出端点
|
||||||
assertEquals("https://mdmuse.kasugano.cn/push_download",
|
assertEquals("https://mdmuse.kasugano.cn/push_download",
|
||||||
PushDownload.endpoint(PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT)));
|
PushDownload.endpoint(PushDownload.effectiveServerUrl("", SourceHosts.DEFAULT)));
|
||||||
|
// 自建源不在第一位(新默认就是这样)也要能找到
|
||||||
|
List<String> officialFirst = Arrays.asList(
|
||||||
|
"http://zk.ktv1.top:6677", "https://mdmuse.kasugano.cn");
|
||||||
|
assertEquals("https://mdmuse.kasugano.cn",
|
||||||
|
PushDownload.effectiveServerUrl("", officialFirst));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -114,13 +151,12 @@ public class SourceHostsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void pushTargetStaysOffWhenFirstSourceIsOfficial() {
|
public void pushTargetStaysOffWhenNoSelfHostSource() {
|
||||||
List<String> officialFirst = new ArrayList<String>();
|
List<String> officialOnly = new ArrayList<String>();
|
||||||
officialFirst.add("http://zk.ktv1.top:6677");
|
officialOnly.add("http://zk.ktv1.top:6677");
|
||||||
officialFirst.add("https://mdmuse.kasugano.cn");
|
assertEquals("列表里没有自建源 → 不自动开推送", "",
|
||||||
assertEquals("首源不是自建源 → 不自动开推送", "",
|
PushDownload.effectiveServerUrl("", officialOnly));
|
||||||
PushDownload.effectiveServerUrl("", officialFirst));
|
assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("", officialOnly)));
|
||||||
assertNull(PushDownload.endpoint(PushDownload.effectiveServerUrl("", officialFirst)));
|
|
||||||
assertEquals("没有任何源 → 关闭", "",
|
assertEquals("没有任何源 → 关闭", "",
|
||||||
PushDownload.effectiveServerUrl("", Collections.<String>emptyList()));
|
PushDownload.effectiveServerUrl("", Collections.<String>emptyList()));
|
||||||
// 配置非法(漏写协议)仍按关闭处理(沿用旧口径)
|
// 配置非法(漏写协议)仍按关闭处理(沿用旧口径)
|
||||||
|
|||||||
@@ -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<String> 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<String> out = new ArrayList<String>();
|
||||||
|
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<String> jsHosts = jsList(RepoFiles.ktvApiJs(), "HOSTS");
|
||||||
|
List<String> jsHost = new ArrayList<String>();
|
||||||
|
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**"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user