From 87aa979abd8ac377f47917552d3e26b473b537b6 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Wed, 14 Oct 2026 02:06:28 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=88=E5=B9=B6=E8=87=AA=E5=BB=BA=E6=AD=8C?= =?UTF-8?q?=E6=9B=B2=E6=BA=90=E6=94=AF=E7=BA=BF=EF=BC=88selfhost-source?= =?UTF-8?q?=EF=BC=89+=20=E7=89=88=E6=9C=AC=E5=8F=B7=E6=8C=89=E7=BA=A6?= =?UTF-8?q?=E5=AE=9A=E4=BF=AE=E6=AD=A3=E4=B8=BA=201.6.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版本号: - 1.5.11 越界(规则 3 规定 patch ≤ 10)→ 修正为 1.6.0(进位到 minor、patch 归 0) - versionCode 43 不变:43 从未对外发布,本次修正只是把命名拉回约定 合并支线(原在工作区根目录、App 仓库之外,DESIGN.md 自称"支线"): - selfhost-source/server/ 自建歌曲源服务器(纯 Python 标准库、零 pip 依赖) - selfhost-source/deploy/、DESIGN.md、LIBRARY.md、build_inventory.py - selfhost-source/inventory/ 小清单(playlists.csv、song-stats.md 等) - ⚠️ inventory 生成物(songs-*.csv / singers-top.csv,共 84MB)不入库, 已写入 .gitignore;需要时用 build_inventory.py 依据 muse.db 重新生成 同时入库的 App 侧支线改动(此前为工作树未提交状态): - Whoami.kt(whoami 设备心跳)、DeviceIdentity.kt(设备身份伪装) - PushDownload.kt / PushDownloadClient.kt(点歌推送) - AdVideoFilter 带 query/fragment 的地址闸门;KTV_BRIDGE_API 11 → 13 - 修 ktv_api.js 的 global.KtvBridgeApiVersion(12 → 13,与注释及 Kotlin 两处校验一致) - 对应单测:WhoamiTest / DeviceIdentityTest / PushDownloadTest / AdVideoFilterTest(共 39 例) 文档: - DESIGN.md 的"在 App 仓库之外"约定改为"已并入仓库统一版本化" - AGENTS.md 版本号行更新为 1.6.0/43,并新增支线并入说明 验证:assembleRelease + testReleaseUnitTest 全过(39 例) --- .gitignore | 7 + AGENTS.md | 32 +- app/build.gradle | 4 +- app/src/main/assets/mobile/ktv_api.js | 39 +- .../main/java/com/local/ktv/AdVideoFilter.kt | 96 +- .../main/java/com/local/ktv/DeviceIdentity.kt | 513 ++++++ app/src/main/java/com/local/ktv/DeviceMac.kt | 20 +- .../main/java/com/local/ktv/KtvJsBridge.kt | 15 +- app/src/main/java/com/local/ktv/KtvStore.kt | 34 + .../main/java/com/local/ktv/MainActivity.kt | 215 ++- .../main/java/com/local/ktv/PushDownload.kt | 128 ++ .../java/com/local/ktv/PushDownloadClient.kt | 86 + app/src/main/java/com/local/ktv/Song.kt | 6 +- .../main/java/com/local/ktv/SongApiClient.kt | 40 +- .../com/local/ktv/SongOkDownloadManager.kt | 5 +- .../java/com/local/ktv/SongUrlResolver.kt | 37 +- app/src/main/java/com/local/ktv/Whoami.kt | 155 ++ .../java/com/local/ktv/AdVideoFilterTest.java | 67 + .../com/local/ktv/DeviceIdentityTest.java | 226 +++ .../java/com/local/ktv/PushDownloadTest.java | 174 ++ .../test/java/com/local/ktv/WhoamiTest.java | 164 ++ selfhost-source/DESIGN.md | 329 ++++ selfhost-source/LIBRARY.md | 67 + selfhost-source/build_inventory.py | 236 +++ selfhost-source/deploy/iis/README.md | 448 +++++ .../deploy/iis/api-site/web.config | 245 +++ .../iis/applicationHost.config.snippet.xml | 69 + .../deploy/iis/media-site/web.config | 129 ++ selfhost-source/deploy/iis/web.config | 41 + selfhost-source/inventory/playlists.csv | 277 +++ selfhost-source/inventory/song-stats.md | 134 ++ selfhost-source/server/README.md | 1515 ++++++++++++++++ selfhost-source/server/deploy/deploy-root.sh | 98 ++ .../server/deploy/ktv-source.service | 34 + .../server/deploy/nginx-media.conf.sample | 32 + selfhost-source/server/ktv/__init__.py | 3 + selfhost-source/server/ktv/admin.py | 677 +++++++ selfhost-source/server/ktv/apptoken.py | 520 ++++++ selfhost-source/server/ktv/batch.py | 251 +++ selfhost-source/server/ktv/config.py | 692 ++++++++ selfhost-source/server/ktv/downloader.py | 655 +++++++ selfhost-source/server/ktv/health.py | 126 ++ selfhost-source/server/ktv/identity.py | 637 +++++++ selfhost-source/server/ktv/main.py | 287 +++ selfhost-source/server/ktv/maintain.py | 652 +++++++ selfhost-source/server/ktv/musesync.py | 325 ++++ selfhost-source/server/ktv/proxy.py | 364 ++++ selfhost-source/server/ktv/pushdl.py | 313 ++++ selfhost-source/server/ktv/resolver.py | 406 +++++ selfhost-source/server/ktv/service.py | 184 ++ selfhost-source/server/ktv/store.py | 1559 +++++++++++++++++ selfhost-source/server/ktv/ui.py | 939 ++++++++++ selfhost-source/server/ktv/web.py | 367 ++++ selfhost-source/server/ktv/whoami.py | 384 ++++ selfhost-source/server/run.py | 15 + .../server/tests/test_admin_perf.py | 211 +++ .../server/tests/test_app_token.py | 660 +++++++ .../server/tests/test_autoretry_scan.py | 720 ++++++++ selfhost-source/server/tests/test_basic.py | 322 ++++ selfhost-source/server/tests/test_identity.py | 379 ++++ selfhost-source/server/tests/test_keeps.py | 886 ++++++++++ .../server/tests/test_keeps_import.py | 372 ++++ selfhost-source/server/tests/test_maintain.py | 743 ++++++++ .../server/tests/test_proxy_iis.py | 553 ++++++ .../server/tests/test_push_download.py | 562 ++++++ selfhost-source/server/tests/test_service.py | 164 ++ selfhost-source/server/tests/test_tasks.py | 431 +++++ selfhost-source/server/tests/test_whoami.py | 334 ++++ 68 files changed, 21305 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/com/local/ktv/DeviceIdentity.kt create mode 100644 app/src/main/java/com/local/ktv/PushDownload.kt create mode 100644 app/src/main/java/com/local/ktv/PushDownloadClient.kt create mode 100644 app/src/main/java/com/local/ktv/Whoami.kt create mode 100644 app/src/test/java/com/local/ktv/DeviceIdentityTest.java create mode 100644 app/src/test/java/com/local/ktv/PushDownloadTest.java create mode 100644 app/src/test/java/com/local/ktv/WhoamiTest.java create mode 100644 selfhost-source/DESIGN.md create mode 100644 selfhost-source/LIBRARY.md create mode 100644 selfhost-source/build_inventory.py create mode 100644 selfhost-source/deploy/iis/README.md create mode 100644 selfhost-source/deploy/iis/api-site/web.config create mode 100644 selfhost-source/deploy/iis/applicationHost.config.snippet.xml create mode 100644 selfhost-source/deploy/iis/media-site/web.config create mode 100644 selfhost-source/deploy/iis/web.config create mode 100644 selfhost-source/inventory/playlists.csv create mode 100644 selfhost-source/inventory/song-stats.md create mode 100644 selfhost-source/server/README.md create mode 100644 selfhost-source/server/deploy/deploy-root.sh create mode 100644 selfhost-source/server/deploy/ktv-source.service create mode 100644 selfhost-source/server/deploy/nginx-media.conf.sample create mode 100644 selfhost-source/server/ktv/__init__.py create mode 100644 selfhost-source/server/ktv/admin.py create mode 100644 selfhost-source/server/ktv/apptoken.py create mode 100644 selfhost-source/server/ktv/batch.py create mode 100644 selfhost-source/server/ktv/config.py create mode 100644 selfhost-source/server/ktv/downloader.py create mode 100644 selfhost-source/server/ktv/health.py create mode 100644 selfhost-source/server/ktv/identity.py create mode 100644 selfhost-source/server/ktv/main.py create mode 100644 selfhost-source/server/ktv/maintain.py create mode 100644 selfhost-source/server/ktv/musesync.py create mode 100644 selfhost-source/server/ktv/proxy.py create mode 100644 selfhost-source/server/ktv/pushdl.py create mode 100644 selfhost-source/server/ktv/resolver.py create mode 100644 selfhost-source/server/ktv/service.py create mode 100644 selfhost-source/server/ktv/store.py create mode 100644 selfhost-source/server/ktv/ui.py create mode 100644 selfhost-source/server/ktv/web.py create mode 100644 selfhost-source/server/ktv/whoami.py create mode 100644 selfhost-source/server/run.py create mode 100644 selfhost-source/server/tests/test_admin_perf.py create mode 100644 selfhost-source/server/tests/test_app_token.py create mode 100644 selfhost-source/server/tests/test_autoretry_scan.py create mode 100644 selfhost-source/server/tests/test_basic.py create mode 100644 selfhost-source/server/tests/test_identity.py create mode 100644 selfhost-source/server/tests/test_keeps.py create mode 100644 selfhost-source/server/tests/test_keeps_import.py create mode 100644 selfhost-source/server/tests/test_maintain.py create mode 100644 selfhost-source/server/tests/test_proxy_iis.py create mode 100644 selfhost-source/server/tests/test_push_download.py create mode 100644 selfhost-source/server/tests/test_service.py create mode 100644 selfhost-source/server/tests/test_tasks.py create mode 100644 selfhost-source/server/tests/test_whoami.py diff --git a/.gitignore b/.gitignore index 322c142..ba4baf4 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,10 @@ gradle.properties # Temp files .tmp_muse.db tools/__pycache__/ + +# 自建歌曲源(selfhost-source 支线)并入仓库后的约定: +# 生成物不入库(由 selfhost-source/build_inventory.py 依据 muse.db 重新生成) +selfhost-source/inventory/songs-*.csv +selfhost-source/inventory/singers-top.csv +selfhost-source/**/__pycache__/ +selfhost-source/**/*.pyc diff --git a/AGENTS.md b/AGENTS.md index f0197b0..b548352 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,8 +195,8 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.5.10"` / `versionCode 42` - (`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,均尚未发布。18~21 曾被本机临时诊断包占用) +- `app/build.gradle`:`versionName "1.6.0"` / `versionCode 43` + (`1.5.5`/`37` 已发布;`1.5.6` = 更新源加 Gitee、`1.5.7` = 删除「歌曲源临时受限」弹窗、`1.5.8` = 精简远程日志、`1.5.9` = 按 HAR 对齐原版协议、`1.5.10` = 源列表只留 zk.ktv1.top:6677、`1.5.11`(命名越界,已改) / `1.6.0` = whoami 心跳/设备身份/点歌推送/带 query 的地址闸门 + 并入 selfhost-source 支线,均尚未发布。18~21 曾被本机临时诊断包占用) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -292,6 +292,11 @@ $BT/apksigner verify 目标名.apk `android.log` → **`MiliLog.local("JS …")`(只写本地文件,不上报远程)**。 逐步过程(Try source / Token OK/FAIL / do.php code=)在本地 `logs/ktv.log` 里看; 远程日志只看一行汇总 **`点歌失败 编号=… 请求源=… 详情=…`**。 +- **⚠️ 自建歌曲源支线(`selfhost-source/`,2026-10-13 并入本仓库)**:自建歌曲源服务器(纯 Python 标准库,零 pip 依赖) + 已从"App 仓库之外的工作区目录"**并入本仓库**统一版本化;`inventory/*.csv` 等生成物由 + `selfhost-source/build_inventory.py` 依据 `muse.db` 重新生成,**不入库**。 + App 侧对应模块:`Whoami.kt`(whoami 心跳)、`DeviceIdentity.kt`(设备身份)、`PushDownload*.kt`(点歌推送)。 + 规则照旧:**支线不改动 App 主体逻辑**,只通过上述独立模块接入。 - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - ⚠️ **`gz.ac16.vip` 是"源站入口"而不是"广告主机"的典型例子(2026-10 实测)**: 该域名对任何 `/<编号>.ts` 都返回**同一段广告视频**,因此它留在 `AdVideoFilter.AD_HOSTS` @@ -341,6 +346,21 @@ $BT/apksigner verify 目标名.apk 现在 `code == 403` 也置 `sawForbidden`(i.php 与 do.php 两处)。 - 日志分工照旧:远程一条 `SOURCE-CONFIG trial_enabled=… trial_times=… …`; 原始 JSON 与"要求卡密"提示只写本地。 +- **⚠️ whoami 设备心跳(2026-10-11 抓包补齐,第三个请求)**:官方客户端启动后先 + `POST hbeat.ktvsky.com/whoami?<24 个字段>`(空 body、`Dalvik/2.1.0 …` UA)登记设备身份, + 再 `i.php` → `do.php`;响应 `{"code":200,"next_interval":3600}` 决定下次上报间隔。 + 约定: + 1. 字段构造/响应解析抽到 **`Whoami.kt`(纯 JVM 逻辑,有单测 `WhoamiTest`)**, + `MainActivity.sendDeviceHeartbeat()` 只负责填 `Build.*`、发请求、按 `next_interval` 重排 + (`_boot=1` 只在**启动那一次**,之后为 0); + 2. `_pkn` 用设置项 **`KtvStore.whoamiPkn`**(默认 `com.origjoy.local.ktv` = + `Whoami.DEFAULT_PKN`,单一来源);`_mac`/`androidid`/`_cid` 三者同源见「设备 MAC」; + 3. **必须在后台线程 + 8 秒超时**,失败只写本地日志(`MiliLog.localWarn("WHOAMI …")`), + 绝不影响取址/播放,也绝不阻塞启动; + 4. 服务端对应实现 `selfhost-source/server/ktv/whoami.py`(配置 `whoami_*`, + `GET /api/whoami` / `POST /api/whoami/send`),字段口径与 App 保持一致; + 5. 参数拼接与取址协议一样**逐字节对齐抓包**(顺序、编码都别改); + 回归证据在服务端 `tests/test_basic.py::TestCanonicalSign`(现网 URL 与 HAR 逐字节相同)。 - **⚠️ 取址有两条实现,改协议必须同时改**:JS 主路径(`ktv_api.js`)+ 原生兜底 (`SongUrlResolver.kt`,供 **WebView 不可用**的老盒子使用 —— 实测极米 H1S/GMUI2.0 上 WebView 导不出 `window.KtvApi`,桥永远 not ready,只靠 JS 时表现为 @@ -368,6 +388,14 @@ $BT/apksigner verify 目标名.apk **URL 里的 `.ts` 文件名必须等于点歌编号**(真源恒为 `/.ts`,已实测 `txog.ktvsky.com` / `download.origjoy.com` 两个 CDN 域)。旧规则只比对"纯数字文件名", `my_ad_video.ts` 这类非数字名字直接漏过; + ⚠️ **2026-10-11 补充(带 query 的地址)**:媒体地址恒为 `…/<编号>.ts?token=…&t=…`, + 因此判定**必须先剥离 query/fragment**:`.ts` 比对只看 **path**、广告特征只看 **host + path** + (用 `AdVideoFilter.hostOf()/pathOf()`,`java.net.URI` 失败会手工兜底)。 + 旧实现在 `#fragment` 上会**整条文件名规则失效(漏放广告)**,拿整串搜 `wb66`/`demo` + 又会误杀 query 里的签名串("地址不可信"的隐蔽来源)。裸 IP / `pub-*.r2.dev/ss/` + 也已收进这道闸门 —— 四条路径不再各判一套。JS 侧同步改(`splitUrl()`), + `KTV_BRIDGE_API` 已升到 **13**;回归用例见 `AdVideoFilterTest`(带 query/fragment 的 + 真源放行、广告仍拒)与服务端 `tests/test_basic.py::TestQueryStringGate`; 3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整), 所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件** (`purgeCachedSongFile`),否则 `isDownloaded()` 会一直复用那个广告文件,广告反复播; diff --git a/app/build.gradle b/app/build.gradle index 6e693a5..60e45ef 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 42 - versionName "1.5.10" + versionCode 43 + versionName "1.6.0" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js index de7647b..c32c55d 100644 --- a/app/src/main/assets/mobile/ktv_api.js +++ b/app/src/main/assets/mobile/ktv_api.js @@ -12,7 +12,7 @@ (function(global) { 'use strict'; - // KTV_BRIDGE_API: 12 (Android 缓存校验标记,请勿删除) + // KTV_BRIDGE_API: 13 (Android 缓存校验标记,请勿删除) // ─── 配置 ────────────────────────────────────────── const CONFIG = { @@ -125,28 +125,49 @@ return JSON.parse(body.slice(start)); } + // 把 URL 拆成 host / path(**剥离 query 与 fragment**)。 + // 所有"看路径/看域名"的判定都基于它:真源地址带 `?token=…&t=…`(2026-10-11 抓包), + // 拿整串判定就会出现"带 query 匹配不到文件名"(漏放)或"query 里的随机串被当广告特征"(误杀)。 + function splitUrl(url) { + var s = String(url || ''); + var head = s.split('#')[0].split('?')[0]; + var m = head.match(/^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\/([^\/]*)(\/.*)?$/); + if (!m) return { host: '', path: head }; + var host = m[1].split('@').pop(); + if (host.charAt(0) === '[') { + host = host.indexOf(']') > 0 ? host.slice(0, host.indexOf(']')) : host; + } else { + host = host.replace(/:\d+$/, ''); + } + return { host: host.toLowerCase(), path: m[2] || '' }; + } + // 匹配 demo/广告/测试资源 URL 的多种模式: // 1) wb66 / demo 关键字 (服务端注入广告示例: https://wb66.cn/f/d/mJMSQ/719.ts) // 2) IPv4 地址 (如 http://154.222.26.244:656/722.ts) // 3) Cloudflare R2 demo bucket (pub-*.r2.dev/ss/ 通用测试文件) // 4) 任务4: ts 文件名编号与请求的 musicno 不一致 → 视为广告/错误资源 // (例: 请求 7018878 却返回 /719.ts 或 /689.ts) + // ⚠️ 判定一律在 **host + path** 上做(见 splitUrl),**不搜 query**。 function isDemoUrl(url, musicno) { var s = String(url || ''); - if (/(?:wb66|demo)/i.test(s)) return true; + var u = splitUrl(s); + var hostPath = u.host + u.path; + if (/(?:wb66|demo)/i.test(hostPath)) return true; // 广告视频专用域名(含子域)。2026-09-26 事故:新设备(WebView 正常 → 走本 JS 取址) // 点歌拿到了 gz.ac16.vip 的广告视频,而它通过了下述所有旧规则 → 被当成歌曲下载播放。 - if (/^https?:\/\/(?:[^\/]+\.)?ac16\.vip(?::\d+)?\//i.test(s)) return true; - if (/\/ad_files\/|my_ad_video/i.test(s)) return true; - if (/^https?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(?::\d+)?\//i.test(s)) return true; + if (/(?:^|\.)ac16\.vip$/i.test(u.host)) return true; + if (/\/ad_files\/|my_ad_video/i.test(u.path)) return true; + if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(u.host)) return true; // R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源 - if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true; + if (/^pub-[a-z0-9]+\.r2\.dev$/i.test(u.host) && /\/ss\//i.test(u.path)) return true; // ts 文件名必须等于 musicno。原版真源 URL 恒为 /.ts(已实测两个 CDN 域 - // txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此)。 + // txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此),且**带 query/fragment**。 // ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字 // 会直接漏过 —— 这正是广告漏放的直接原因之一。 + // ⚠️ 旧正则收尾写的是 `(?:\?|$)`:遇到 `#fragment` 会**完全匹配不到 → 整条规则被跳过**。 if (musicno) { - var m = s.match(/\/([^\/?]+)\.ts(?:\?|$)/i); + var m = u.path.match(/\/([^\/]+)\.ts(?:\/|$)/i); if (m && m[1] !== String(musicno)) return true; } return false; @@ -437,7 +458,7 @@ // ─── 导出 ────────────────────────────────────────── global.KtvApi = KtvApi; global.KtvApiConfig = CONFIG; - global.KtvBridgeApiVersion = 12; + global.KtvBridgeApiVersion = 13; if (typeof module !== 'undefined' && module.exports) { module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; diff --git a/app/src/main/java/com/local/ktv/AdVideoFilter.kt b/app/src/main/java/com/local/ktv/AdVideoFilter.kt index 72a3d13..8a97d2c 100644 --- a/app/src/main/java/com/local/ktv/AdVideoFilter.kt +++ b/app/src/main/java/com/local/ktv/AdVideoFilter.kt @@ -15,15 +15,21 @@ import android.util.Log object AdVideoFilter { private const val TAG = "AdVideoFilter" - /** 命中这些片段的地址视为广告/测试资源(匹配小写)。 */ + /** + * 命中这些片段的地址视为广告/测试资源(匹配小写)。 + * + * ⚠️ 只在 **host + path** 上匹配(见 [isAdVideo]),**不搜 query** —— 因此这里可以安全地 + * 收 `demo`:真源地址的 `?token=…&t=…` 是签名/随机串,旧实现拿整串匹配时 `demo` 会误杀 + * 真源,故当时只收了 `wb66` 这种高区分度标记。现在与 JS `isDemoUrl`、服务端 + * `resolver.is_trusted_url` 三条实现口径一致(都是 host+path 上的 `wb66|demo`)。 + */ private val AD_MARKERS = listOf( "/ad_files/", "my_ad_video", // 源站 2026-10 起"授权窗口失效"时会对每首歌返回同一个测试资源 - // (实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。JS 的 isDemoUrl 早就把 wb66 当 - // 测试资源,这里对齐,保证两条取址路径判定一致。 - // ⚠️ 只收"wb66"这种高区分度标记:`demo` 之类太宽松的词会误伤真源 URL 里的随机 token。 + // (实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。 "wb66", + "demo", ) /** @@ -47,13 +53,20 @@ object AdVideoFilter { @JvmField var replacementUrl: String? = null - /** 判断是否为广告视频地址(路径特征 + 域名)。 */ + /** + * 判断是否为广告视频地址(路径特征 + 域名)。 + * + * ⚠️ 特征只在 **host + path** 上匹配(**不搜 query**):真源地址带 `?token=…&t=…`, + * query 里是签名/随机串,拿整串搜 `wb66`/`demo` 会把合法地址误杀(见 [isTrustedSongUrl])。 + */ @JvmStatic fun isAdVideo(url: String?): Boolean { if (url.isNullOrEmpty()) return false - val lower = url.lowercase() + val host = hostOf(url) + // host 取不到(畸形 URL)时退化为整串匹配:**宁可拦下,也不要放行广告**。 + val lower = ((host ?: url) + pathOf(url)).lowercase() if (AD_MARKERS.any { lower.contains(it) }) return true - val host = hostOf(url) ?: return false + if (host == null) return false return AD_HOSTS.any { host == it || host.endsWith(".$it") } } @@ -63,26 +76,83 @@ object AdVideoFilter { * 判定为不可信的情况: * 1. 非 http(s); * 2. 命中广告特征([isAdVideo]); - * 3. **URL 里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号 + * 3. **URL 路径里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号 * (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。 * 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行, * 这正是本次广告漏放的直接原因。 + * + * ⚠️ 第 3 条**必须在剥离 query/fragment 后的 path 上做**:真源地址带 `?token=…&t=…` + * (2026-10-11 抓包:`…/480p/7200088.ts?token=…&t=…`)。旧正则写成 + * `/([^/?]+)\.ts(?:\?|$)` 只认 `?` 结尾,遇到 `#fragment` 就**完全匹配不到 → 整条文件名 + * 规则被跳过(漏放)**;而拿整串找特征又会把 query 里的签名串当广告特征(误杀)。 */ @JvmStatic fun isTrustedSongUrl(url: String?, musicno: String?): Boolean { if (url.isNullOrEmpty()) return false if (!url.startsWith("http://") && !url.startsWith("https://")) return false if (isAdVideo(url)) return false - val name = TS_BASENAME.find(url)?.groupValues?.getOrNull(1) + val host = hostOf(url) ?: return false + val path = pathOf(url) + // 测试资源主机(与 JS `isDemoUrl` / 服务端 `resolver.is_trusted_url` 同口径): + // 裸 IP —— App 只接受域名;`pub-*.r2.dev/ss/` —— Cloudflare 公开测试桶。 + if (IP_HOST.matches(host)) return false + if (R2_HOST.matches(host) && path.contains("/ss/")) return false + val name = tsBasename(path) if (name != null && !musicno.isNullOrEmpty() && name != musicno) return false return true } - private fun hostOf(url: String): String? = - runCatching { java.net.URI(url).host?.lowercase() }.getOrNull() + /** + * 取 URL 的 **host**(小写,不含端口);`java.net.URI` 解析失败时退化为手工提取。 + * + * 退化的意义:query 里带 `|`、空格这类字符会让 `URI()` 抛异常,旧实现(把异常当成 + * "无法判定")会把**合法真源地址误判成 demo 并拒绝** —— 这是"取址失败"的一个隐蔽来源。 + */ + @JvmStatic + fun hostOf(url: String?): String? { + val s = url ?: return null + runCatching { java.net.URI(s).host?.lowercase() }.getOrNull()?.let { if (it.isNotEmpty()) return it } + val head = s.substringBefore('#').substringBefore('?') + val m = Regex("^[a-zA-Z][a-zA-Z0-9+.\\-]*://([^/]*)").find(head) ?: return null + var host = m.groupValues.getOrNull(1).orEmpty().substringAfterLast('@') + if (host.startsWith("[")) host = host.substringBefore(']') // IPv6 字面量 + else host = host.substringBefore(':') + return host.lowercase().ifEmpty { null } + } - /** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对。 */ - private val TS_BASENAME = Regex("/([^/?]+)\\.ts(?:\\?|$)", RegexOption.IGNORE_CASE) + /** + * 取 URL 的 **path**(**已剥离 query 与 fragment**)。 + * + * 所有"看路径"的判定(`.ts` 文件名、`/ad_files/` 等)都必须用它, + * 不能拿整串 —— 否则 query 一变判定就变。 + */ + @JvmStatic + fun pathOf(url: String?): String { + val s = url ?: return "" + runCatching { java.net.URI(s).rawPath }.getOrNull()?.let { if (it != null) return it } + val head = s.substringBefore('#').substringBefore('?') + val m = Regex("^[a-zA-Z][a-zA-Z0-9+.\\-]*://[^/]*(/.*)$").find(head) + return m?.groupValues?.getOrNull(1).orEmpty() + } + + /** + * 取 **path** 里 `.ts` 的文件名主体(不含扩展名);path 里没有 `.ts` 则返回 null + * (此时不做文件名比对,避免误杀非 ts 的真源)。 + * + * 例:`/264/lvbr/480/E/ts/39.6/202309070206/480p/7200088.ts` → `7200088`。 + */ + @JvmStatic + fun tsBasename(path: String?): String? = + TS_BASENAME.find(path.orEmpty())?.groupValues?.getOrNull(1) + + /** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对(入参是 path)。 */ + private val TS_BASENAME = Regex("/([^/]+)\\.ts(?:/|$)", RegexOption.IGNORE_CASE) + + /** 裸 IP 主机(IPv4 字面量):App 只接受域名形式的媒体地址。 */ + private val IP_HOST = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$") + + /** Cloudflare R2 公开测试桶主机(配合 path 里的 `/ss/` 判定,见 `isTrustedSongUrl`)。 */ + private val R2_HOST = Regex("^pub-[a-z0-9]+\\.r2\\.dev$", RegexOption.IGNORE_CASE) /** * 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换); diff --git a/app/src/main/java/com/local/ktv/DeviceIdentity.kt b/app/src/main/java/com/local/ktv/DeviceIdentity.kt new file mode 100644 index 0000000..feda317 --- /dev/null +++ b/app/src/main/java/com/local/ktv/DeviceIdentity.kt @@ -0,0 +1,513 @@ +package com.local.ktv + +import java.security.SecureRandom +import java.util.Random + +/** + * 设备身份(**伪装成一台安卓盒子**):内置真盒子模板 + 随机 MAC/SN,生成一次后**固定不变**。 + * + * ## 为什么需要它 + * 官方 App 是三个请求都带设备身份:`POST hbeat.ktvsky.com/whoami`(心跳)、 + * `GET <源站>/i.php?…&mac=_`(取 token)、 + * `GET <源站>/music/do.php?…&device=_`(取歌地址)。 + * 以前本 App 上报的是**真实机型**(`Build.MODEL` / `Settings.Secure.ANDROID_ID`), + * 服务端上报的是真实 Linux 机器 —— 两侧都一眼不是"麦动盒子"。现在统一成: + * **随机挑一套常见安卓盒子模板 → 随机 mac/sn/ktv_id → 落盘 → 三个请求同源**。 + * 服务端对应实现 `selfhost-source/server/ktv/identity.py`(模板与生成规则**一一对应**)。 + * + * ## ⚠️ mac 是"构造值",不是网卡真实 MAC + * Android 6 起 `WifiInfo.getMacAddress()` 恒返回 `02:00:00:00:00:00`,Android 10+ 更是 + * 完全禁止读取真实 MAC;即便读得到([DeviceMac]),也不该拿真实网卡地址去上报。 + * 这里前 3 字节用**真实盒子/芯片厂商的 OUI**(Amlogic/Realtek/ODM…)、后 3 字节随机, + * 既是**单播**地址(第一个八位组最低位为 0,组播位为 1 一眼假),又不会跟别人撞车。 + * 真实网卡 MAC / 真实机型只在**本地日志**里保留,便于排查。 + * + * ## 为什么不用 `org.json` / `android.*` + * 单测(`app/src/test`)跑在**纯 JVM** 上,Android SDK 的 `org.json`/`Build`/`Log` 全是 + * 会抛 `Stub!` 的空壳;因此这里只用 `java.*`,JSON 也手写一个**扁平**的读写(字段全是字符串)。 + * + * ## 持久化 + * 存进 [KtvStore](它落在 `<存储根>/state.json`,即本项目的 SharedPreferences 等价物)。 + * 身份一旦生成就**稳定不变** —— 上游按 device 识别设备,身份一变就会被当成新设备, + * 之前建立的"合法/活跃"状态全没了。想换身份只能显式走 [regenerate](设置页有入口)。 + */ +object DeviceIdentity { + + /** `_vc`:原版客户端 versionCode(抓包值,与服务端 `ktv/identity.py` 同源口径)。 */ + const val VC = 4 + + /** `_pkn`:伪装成原版包名(可被设置项 [KtvStore.whoamiPkn] 覆盖)。 */ + const val DEFAULT_PKN = "com.origjoy.local.ktv" + + /** `ktv_id` 兜底值(身份里会随机生成一个 8 位数字)。 */ + const val DEFAULT_KTV_ID = "10006789" + + /** `d_user`:原版按 dpi 上报(抓包值)。 */ + const val DEFAULT_D_USER = "dpi" + + /** 身份 JSON 的版本号(字段增删时升它,便于以后做迁移)。 */ + const val VERSION = 1 + + /** `_vn`:与原版取址协议(i.php / do.php)同一口径,见 [Whoami.VN]。 */ + const val VN = Whoami.VN + + /** + * 一套盒子模板:**字段必须互相自洽**(`_manuf` 与 fingerprint 的品牌段一致、 + * `release` 与 `_api` 对得上、OUI 是这家/这类盒子的真实前缀)。 + * + * @param ouis 该模板可用的 MAC 前缀(真实盒子厂商 OUI,全部是单播地址)。 + */ + data class Template( + val preset: String, + val label: String, + val manuf: String, + val model: String, + val name: String, + val device: String, + val board: String, + val hardware: String, + val release: String, + val buildId: String, + val incremental: String, + val ouis: List, + ) + + /** + * 内置模板:与 `selfhost-source/server/ktv/identity.py` 的 `TEMPLATES` 一一对应 + * (Amlogic S905X / S905X3 / S905X4、Rockchip RK3328、Realtek RTD1295)。 + * + * OUI 取自 IEEE 登记数据:`1ca410` = Amlogic, Inc.、`00e04c` = REALTEK SEMICONDUCTOR; + * `001ec0`/`d43a2e` 是 Amlogic 方案盒子常见前缀;`b49dfd`/`acdbda` 是 + * Shenzhen SDMC / Geniatech(Rockchip 方案盒子的 ODM,RK 自身几乎没有成品盒子 OUI)。 + */ + @JvmField + val TEMPLATES: List