5 Commits
Author SHA1 Message Date
zijunchiang 39e3c8d617 文档: 补充第 5 条发布约定——发布说明从简,详情记入本地 CHANGELOG
- AGENTS.md「硬性规则」由四条扩为五条,新增:Release 正文只列"更新了哪几点"
  (每条一行,不写根因/证据/验证过程),详细信息统一记入本地记录
  /home/zijun/dsh_data/maidongktv/CHANGELOG.md(工作区根目录、仓库之外,
  既不随 Release 发布也不提交进 Gitea),并给出 Release 正文的推荐格式示例。
- 明确分工: CHANGELOG.md = 每个版本"发生了什么"; AGENTS.md = 以后改代码"必须遵守什么"。
- 本地 CHANGELOG.md 已建立(含 1.4.2 ~ 1.5.5 的根因、证据、验证与发布信息,按版本倒序)。
2026-10-07 17:29:23 +08:00
zijunchiang 97ca1ae28e V1.5.5: 加入源 a.ktv8.my;"所有源失效"改为明确弹窗提示
一、加入歌曲源 a.ktv8.my(置于 gz.ac16.vip 之后)
实测(代理修复后,DNS 直连真实 IP 20~42ms):
- 真实 IP 154.219.102.181 —— 与 w345.my **同一台服务器**(同一后台的兄弟入口);
- GET / → 200(917B,主机面板默认占位页);
- i.php → 200,170B,与其它 5 个入口响应**逐字节一致**:
  code=200 / msg=ok / ordertype=0 / end_date=2026-10-05 14:58:21 / retry=5 / heart_time=5400,token 正常下发;
- 自身媒体路径 /<编号>.ts 与 /ad_files/my_ad_video.ts 均 404(138B,与 e.ac19.cn 的 404 逐字节相同)
  → 它不托管广告媒体;
- ⚠️ do.php 取址行为未能定性: 测试期间本机出口 IP(与门店设备同一个 188.253.112.231)
  的配额被反复探测耗尽,表现为"成功一两次后长时间静默丢包"(与代码里记录的
  trial_times:2 × trial_duration:5 吻合)。故置于次位,由运行时升降序自动调整。
KTV_BRIDGE_API 10 → 11(ktv_api.js 两处标记 + KtvJsBridge 两处校验)。

二、"所有源都取不到地址"给出明确提示
新增 Eac19WindowGuard.onAllFailed ← noteAllSourcesFailed(),由 SongUrlResolver.resolve()
在**本轮彻底失败**时调用(403 / 只返回广告 / 网络异常都算),弹窗文案:
    「远程源已全部失效,等待新版本修复」
- 与既有的 onBlocked(风控 403 → "换公网 IP")常常同时触发,因此在 onBlocked 处理器里
  用 lastAllFailedAt() 做 60 秒去重,不会同时弹两个框;两者均 15 分钟节流。
- 以前这里只有一句"无法生成下载地址"的 toast,用户看起来像界面没反应。

版本 1.5.4(36) → 1.5.5(37);签名证书不变。测试 9 例全通过。
2026-10-07 07:53:48 +08:00
zijunchiang c1fd0fa319 V1.5.4: 新增可用源 gz.ac16.vip;修复"点歌失败不入下载列表";下载列表删除按状态区分
一、新增歌曲源 gz.ac16.vip(置于源列表首位)
实测(DoH 取真实 IP 154.222.26.244,绕开本机网关 fake-IP 干扰):
- i.php: HTTP 200,下发 token,end_date=2027-10-05(比现用镜像源的 2026-10-05 更晚);
- music/do.php: 对 7303235/7200188/8614960 分别返回各自不同的真源地址
  (http://txog.ktvsky.com/.../<编号>.ts?token=... / http://download.origjoy.com/.../<编号>.ts?sign=...),
  文件名均等于点歌编号;
- 这些地址能真实下载:HTTP 206 video/MP2T,逐曲内容各不相同,
  TS 流同步字节按 188 字节对齐(41/43 包同步);
- 对照:其余 7 个入口对每首歌(含不存在的编号)统一返回同一段
  https://wb66.cn/f/d/7b7c9/10188555.ts(广告/测试资源),主源 w.w345.my/i.php 直接 403。

因此把 gz.ac16.vip 加入源列表:ktv_api.js 的 CONFIG.HOSTS 与
SongUrlResolver.DEFAULT_HOSTS 各一处(均置于首位)。
⚠️ 边界:该域名**自己**的 /<编号>.ts 永远是同一段广告,所以它继续留在
AdVideoFilter.AD_HOSTS(媒体层拦截);广告黑名单只作用于"返回地址的 host",
不作用于"取址入口的 host",两者不可混淆,否则会误杀唯一可用源。
KTV_BRIDGE_API 9 → 10(ktv_api.js 两处标记 + KtvJsBridge 两处校验),保证设备热更新到新脚本。

二、修复"点歌失败时歌曲不出现在下载列表"
根因:DownloadTask 只在 onDownloadStart 里创建,所以"取址失败(无法生成下载地址)"这类
还没真正开始下载的失败**不会留下任何记录**,下载列表里什么都没有。
修复:downloadSong() 在**发起下载之前**就登记任务(状态「排队中」),
失败时由既有回调就地改成「失败:原因」,条目保留在列表里,可看原因、可重试、可清理。

三、下载列表删除功能(按状态区分语义)
- 有本地文件 → 「删除文件」(confirmDeleteLocalFile,同时清掉下载记录);
- 失败/进行中(无文件)→ 「移出下载列表」(confirmRemoveDownloadEntry):
  只删记录 + stateDatabase.removeDownload,**绝不动任何歌曲文件**
  (历史上"顺手清缓存"删光公放曲库的教训见 AGENTS);
- 「更多」菜单在下载列表内额外提供「移出列表」;
- SongListAdapter 的「更多」图标由装饰改为**可点/可聚焦**(遥控器可达,不再只能长按)。

版本 1.5.3(35) → 1.5.4(36);签名证书不变。测试 9 例全通过。
2026-10-04 00:04:20 +08:00
zijunchiang 96bad3bd68 V1.5.3: 远程日志不再静默失效(+本地日志文件);补 403 受限识别
现场两个问题分别定位:

一、"歌曲源全部失效" —— 已确认是**源站对该出口 IP 的授权/风控**,非客户端可修
- 实测(本机与设备同一出口 188.253.112.231):
  · 主源 http://w.w345.my/i.php → **HTTP 403 Forbidden**(nginx 拦截页);
  · 镜像源(desktok.top / mm.kk456.top / w.w345.my:66 / z.w345.my / b.sktv.top / w345.my)
    返回 200,但对**每一首歌(含不存在的编号 1111111/9999999)都返回同一个广告/测试资源**
    https://wb66.cn/f/d/7b7c9/10188555.ts;
  · 换全新随机设备身份(mac/sn)结果完全相同 → 限制在**出口 IP** 层面,与设备无关;
  · 7 host × ls=1~3 × (720/1080 × h265 0/1) 全组合探测:0 个真源;
  · 旧的试用领取接口 api.php 已 **404**,App 无法自行领取窗口。
- 结论:源站侧无有效授权(i.php 的 ordertype=0、mwsLogin 的 license_box_count=0 /
  license_duration=0 均指向此)。**需更换公网 IP 或与歌曲源服务方确认续订**。
  我们拒绝这些地址是正确行为(否则就是 1.4.10 修过的"广告被当成歌曲播放")。

二、"手动更新数据库后远程日志失效" —— 数据库没问题,日志客户端有隐患,两者都已处理
- 实测新曲库 day=261003(md5 300AFD957BF332F02C17ED94A9F4E4CF,431,530,367 字节,
  解压 1.01GB、69.5 万首): 用 App 的**真实 SQL**(SONG_SELECT/推荐/中文搜索/拼音搜索/
  歌手检索/歌单/语种/歌手列表)逐条跑通 → **结构完全兼容,更新曲库不会破坏 App**。
- 日志侧修复(原来确实可能"静默失效"):
  · MiliLog.init 改为**可重复调用并自动重启发送线程**(旧实现 `if (started.getAndSet(true)) return`,
    线程一旦退出就永久不再发送且无任何迹象);
  · sendLoop 整体加 catch(Throwable) 兜底,任何异常都不允许发送线程退出;
  · 新增**本地日志文件** <存储根>/logs/ktv.log(>1MB 滚动为 .prev),
    远程日志不可用时现场可直接取文件;启动打 APP-INIT 行给出路径;
  · MiliLog.init 提前到 KtvApplication.onCreate,早于 MainActivity,早期崩溃也有日志。
- 补 403 识别: KtvJsBridge 的 403 判定从仅 /music/do.php 扩展到 /i.php 也认
  (主源 i.php 就是 403);SongUrlResolver 记录本轮是否见过 403,全部失败且见过 403 时
  同样走 Eac19WindowGuard 提示换 IP。

AdVideoFilter: 仅保留高区分度标记 wb66,去掉过于宽松的 demo(避免误伤真源 URL 里的随机 token)。

版本 1.5.2(34) → 1.5.3(35);签名证书不变。测试 9 例全通过。
2026-10-03 21:03:07 +08:00
zijunchiang 3b75717c81 V1.5.2: 识别"源站只发广告/测试资源"的新受限形态(歌曲源全部失效的真因)
用户报"歌曲源全部失效"。逐层实测结论:**不是代码 bug,是源站对该出口 IP 的授权窗口失效**。

实测证据(本机与设备同一出口 IP 188.253.112.231,设备 LAN 192.168.100.177/.83、本机 .203):
- i.php 仍认证通过: code=200 / end_date=2026-10-05 / ordertype=0 / retry=5;
- do.php 对**每一首歌**(包括不存在的编号 1111111、9999999)都返回**同一个**资源
  https://wb66.cn/f/d/7b7c9/10188555.ts —— 而 wb66 正是代码里早就标注的广告/测试资源域名;
- 换全新随机设备身份(mac/sn)结果相同 → 限制记在**出口 IP** 上,与设备无关;
- 7 个 host × ls=1~3 × (720/1080, h265 0/1) 全组合探测:0 个返回真源;
- mwsLogin 返回 authorized:true,但 license_box_count=0 / license_duration=0(无有效授权额度)。

即状态从"能用"变成了"只发广告"(此前同一身份能拿到 txog.ktvsky.com / download.origjoy.com 的真源)。
我们拒绝广告地址本身是**正确**的(否则就是 1.4.10 修过的"广告被当成歌曲播放"),
所以用户看到的"全部失效"其实是"源站不再给真源 + 我们拒绝广告"的叠加结果。

修复(可操作化,避免用户只看到一句"无法生成下载地址"):
- SongUrlResolver 统计"全部源只返回不可信地址"这一形态,识别为授权窗口失效:
  · 打 SOURCE-BLOCKED 日志(含示例 url,便于远程取证);
  · 调用 Eac19WindowGuard.noteRiskBlocked() → 弹「歌曲源临时受限」提示,
    明确告知"额度记在出口 IP 上、换设备无效",引导重启光猫/路由器更换公网 IP;
  · 进入 3 分钟冷却(BLOCKED_COOLDOWN_MS):期间快速失败,避免继续冲击源站把限流拖更久;
    拿到真源立即解除冷却。
- AdVideoFilter 的广告特征补上 wb66 / demo(与 JS 的 isDemoUrl 对齐,两条取址路径判定一致)。
- 提示文案补充"或对每首歌只返回广告/测试资源"这一新表现。
- 新增回归测试:用实测 URL https://wb66.cn/f/d/7b7c9/10188555.ts 锁住拦截行为。
- Eac19WindowGuard 文档补充两种受限形态。

现场恢复办法:更换公网出口 IP(重启光猫/路由器重拨)。已下载歌曲不受影响,可正常播放。

版本 1.5.1(33) → 1.5.2(34);签名证书不变。测试 9 例全通过。
2026-10-03 20:10:57 +08:00
12 changed files with 406 additions and 45 deletions
+93 -2
View File
@@ -11,7 +11,7 @@
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 代码主仓库,**唯一的代码同步目标** | | `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 代码主仓库,**唯一的代码同步目标** |
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | 📦 **不推代码**,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 | | `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | 📦 **不推代码**,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 |
### 四条硬性规则 ### 五条硬性规则
1. **推送失败只重试 3 次**:Gitea 不可用(网络不通/服务器故障/返回非 200)时,最多重试 **3 次** 1. **推送失败只重试 3 次**:Gitea 不可用(网络不通/服务器故障/返回非 200)时,最多重试 **3 次**
(间隔约 60s)。3 次仍失败就**停止重试**,改动保留为本地 commit,**留到下次一起同步** (间隔约 60s)。3 次仍失败就**停止重试**,改动保留为本地 commit,**留到下次一起同步**
@@ -36,6 +36,27 @@
> 三者的触发条件只有一个 —— **收到明确的人工发布指令**。若不确定是否已发布, > 三者的触发条件只有一个 —— **收到明确的人工发布指令**。若不确定是否已发布,
> 用 `GET /repos/{owner}/{repo}/releases` 与 `/tags` 核对,不要凭"打过包"推断。 > 用 `GET /repos/{owner}/{repo}/releases` 与 `/tags` 核对,不要凭"打过包"推断。
5. **发布说明从简,详情留本地(2026-10 新增)**:Release 正文**只列"更新了哪几点"**——
每条一行,写清"改了什么 / 用户能感知到什么";**不写**根因分析、不贴证据与 URL、
不写验证过程、不列日志片段。**详细信息写在本地记录**
`/home/zijun/dsh_data/maidongktv/CHANGELOG.md`
(工作区根目录、**仓库之外**:既不随 Release 发布,也不提交进 Gitea)。
两者分工:
- **CHANGELOG.md** = 每个版本"发生了什么"(根因/证据/验证/发布信息,按版本倒序);
- **AGENTS.md** = 以后改代码"必须遵守什么"(约定与坑位),不重复 CHANGELOG 的过程叙述。
Release 正文推荐格式(示例):
```
## 麦动KTV V1.5.5
- 新增歌曲源 a.ktv8.my
- 歌曲源全部失效时提示「远程源已全部失效,等待新版本修复」
- 修复:点歌失败后歌曲不出现在下载列表
- 下载列表支持删除(有文件的删文件,失败任务移出列表)
```
> 每次发布/打包后,把该版本写进 CHANGELOG.md(含 `versionCode`、md5、发布位置与校验结果),
> 这样 Release 保持简短、`git log` 保持可读,而"为什么这么改"永远查得到。
- 凭据: - 凭据:
- **Gitea**(代码主仓库):访问令牌 `dsh-sync-20260926`(2026-09-26 数据迁移后重建;可在 Gitea - **Gitea**(代码主仓库):访问令牌 `dsh-sync-20260926`(2026-09-26 数据迁移后重建;可在 Gitea
「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600), 「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600),
@@ -165,7 +186,7 @@ $BT/apksigner verify 目标名.apk
### 版本号 ### 版本号
- `app/build.gradle`:`versionName "1.5.1"` / `versionCode 33`(18~21 被本机临时诊断包占用过) - `app/build.gradle`:`versionName "1.5.5"` / `versionCode 37`(18~21 被本机临时诊断包占用过)
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
@@ -262,6 +283,22 @@ $BT/apksigner verify 目标名.apk
点歌失败时先搜远程日志里的 **`JS `** 与 **`点歌失败`** 两行:前者给 点歌失败时先搜远程日志里的 **`JS `** 与 **`点歌失败`** 两行:前者给
「Try source / Token OK/FAIL / do.php code=」的逐步过程,后者给 JS 汇总的失败原因。 「Try source / Token OK/FAIL / do.php code=」的逐步过程,后者给 JS 汇总的失败原因。
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
- ⚠️ **`gz.ac16.vip` 是"源站入口"而不是"广告主机"的典型例子(2026-10 实测)**:
该域名对任何 `/<编号>.ts` 都返回**同一段广告视频**,因此它留在 `AdVideoFilter.AD_HOSTS`
里(媒体层拦截,必须保留);但它的 `i.php` + `music/do.php` 与其他入口同一后台、同一协议,
**会下发指向真源 CDN(txog.ktvsky.com / download.origjoy.com)的逐曲地址**,
实测可下载且逐曲内容不同。因此已把它**置于源列表首位**
(`ktv_api.js` 的 `CONFIG.HOSTS` 与 `SongUrlResolver.DEFAULT_HOSTS` 各一处)。
**判定原则**:广告黑名单只作用于**返回地址(媒体 URL)的 host**,
**不作用于"取址入口"的 host** —— 两者不能混为一谈,否则会误杀唯一可用的源。
- 该改动同时把 `KTV_BRIDGE_API` 由 9 提升到 **10**(`ktv_api.js` 两处标记 + `KtvJsBridge` 两处校验)。
- **`a.ktv8.my`(2026-10 加入,置于 `gz.ac16.vip` 之后)**:实测与 `w345.my` **同一台服务器**
(`154.219.102.181`),`GET /` 是主机面板默认页,`i.php` 返回与其它入口**逐字节一致**的
170B 响应(`code=200/end_date=2026-10-05/retry=5`)→ 同一后台的兄弟入口;
它自身媒体路径(`/<编号>.ts`、`/ad_files/…`)均 404 → **不托管广告媒体**。
⚠️ 其 `do.php` 取址行为**尚未定性**(测试时本机出口 IP 配额已被消耗殆尽),
因此置于次位、由运行时自动升降序。
- 该条改动把 `KTV_BRIDGE_API` 由 10 提升到 **11**(同样四处)。
- **⚠️ 取址有两条实现,改协议必须同时改**:JS 主路径(`ktv_api.js`)+ 原生兜底 - **⚠️ 取址有两条实现,改协议必须同时改**:JS 主路径(`ktv_api.js`)+ 原生兜底
(`SongUrlResolver.kt`,供 **WebView 不可用**的老盒子使用 —— 实测极米 H1S/GMUI2.0 上 (`SongUrlResolver.kt`,供 **WebView 不可用**的老盒子使用 —— 实测极米 H1S/GMUI2.0 上
WebView 导不出 `window.KtvApi`,桥永远 not ready,只靠 JS 时表现为 WebView 导不出 `window.KtvApi`,桥永远 not ready,只靠 JS 时表现为
@@ -328,6 +365,19 @@ $BT/apksigner verify 目标名.apk
结果按进程缓存,网络切换时由 `remoteNetworkMonitor` 调用 `DeviceMac.refresh()` 重解析。 结果按进程缓存,网络切换时由 `remoteNetworkMonitor` 调用 `DeviceMac.refresh()` 重解析。
消费方:`KtvJsBridge.DEVICE_MAC`(JS 端 `mac`)、whoami 心跳的 `_mac`/`androidid`/`_cid` 消费方:`KtvJsBridge.DEVICE_MAC`(JS 端 `mac`)、whoami 心跳的 `_mac`/`androidid`/`_cid`
(抓包时三者同值,保持一致) (抓包时三者同值,保持一致)
- **⚠️ 下载列表的两条约定(2026-10)**:
1. **任务必须在"发起下载之前"登记**(`downloadSong()` 开头就把 `DownloadTask` 放进 `downloads`,
状态「排队中」)。旧实现只在 `onDownloadStart` 里建任务,于是**取址失败**
(「无法生成下载地址」)这类还没真正开始下载的失败**不留任何记录** ——
用户点歌失败后下载列表空空如也,既看不到原因也不能重试。
失败时由既有回调就地改成「失败:原因」,条目保留在列表里。
2. **下载列表的「删除」按状态区分语义**(`renderSongList` 的 `onDelete` 接线):
有本地文件 → 删除文件(`confirmDeleteLocalFile`,同时清下载记录);
失败/进行中(无文件)→ **移出下载列表**(`confirmRemoveDownloadEntry`)。
「移出列表」只删记录 + `stateDatabase.removeDownload`,**绝不动任何歌曲文件** ——
历史上"顺手清缓存"曾把整个公放曲库删光(1.4.10 事故)。
另外 `SongListAdapter` 的「更多」图标已从装饰改为**可点/可聚焦**(遥控器可达),
下载列表行尾的删除按钮一直都在。
- **播放信息实时刷新**:`refreshNowPlayingLabels()` 是播放界面左上角「当前播放 / 下一首」、首页播放条、 - **播放信息实时刷新**:`refreshNowPlayingLabels()` 是播放界面左上角「当前播放 / 下一首」、首页播放条、
旧版播放区与顶部状态栏的**唯一**刷新入口,只在文本变化时写 View;已挂到 `lyricTicker`(500ms)上, 旧版播放区与顶部状态栏的**唯一**刷新入口,只在文本变化时写 View;已挂到 `lyricTicker`(500ms)上,
因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它, 因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它,
@@ -357,6 +407,22 @@ $BT/apksigner verify 目标名.apk
5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的 5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的
问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...` 问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...`
(版本/在线/可用空间/曲库大小/根目录)提供。 (版本/在线/可用空间/曲库大小/根目录)提供。
- ⚠️ **远程日志绝不能静默失效**(2026-10 现场反馈"手动更新曲库后远程日志失效了"):
1. `MiliLog.init()` **可以重复调用并会自动重启发送线程**(旧实现是
`if (started.getAndSet(true)) return`,一旦线程退出就永久不再发送,且无从察觉);
2. `sendLoop` 整体包了 `catch (Throwable)` 兜底 —— 任何异常都不允许让发送线程退出;
3. **本地留档**:每行同时写入 `<存储根>/logs/ktv.log`(超 1MB 滚动为 `.prev`)。
远程日志不可用时,让现场直接把该文件发出来即可取证;启动时会打一行
`APP-INIT ... logFile=<路径>` 指明位置;
4. `MiliLog.init` 已提到 **`KtvApplication.onCreate`**(早于 `MainActivity`),
这样即使更早的初始化抛异常,也已经有日志可查。
- ⚠️ **"所有源都取不到地址"必须给用户一句明确的话(2026-10 用户要求)**:
`Eac19WindowGuard.onAllFailed` ← `noteAllSourcesFailed()`,
由 `SongUrlResolver.resolve()` 在**本轮彻底失败**时调用(403 / 只发广告 / 网络异常都算),
弹窗文案固定为 **「远程源已全部失效,等待新版本修复」**。
它与 `onBlocked`(风控 403 → "换公网 IP")常常同时触发,因此 `onBlocked` 处理器里
用 `lastAllFailedAt()` 做了 60 秒去重,**不允许同时弹两个框**;两者都按 15 分钟节流。
以前这里只有一句"无法生成下载地址"的 toast,用户看着像界面没反应。
- 一段典型现场日志应为:`BOOT` → `STORAGE` → (点歌)`点歌编号=... 请求源=... 状态=成功 urlHost=...` - 一段典型现场日志应为:`BOOT` → `STORAGE` → (点歌)`点歌编号=... 请求源=... 状态=成功 urlHost=...`
→ `PLAY ... src=local` → 每 6h `HEALTH ...`;异常时才有 `RAW-ERR`/`NET-ERR`/`NATIVE-SONG ...失败`。 → `PLAY ... src=local` → 每 6h `HEALTH ...`;异常时才有 `RAW-ERR`/`NET-ERR`/`NATIVE-SONG ...失败`。
- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询) - **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询)
@@ -373,6 +439,31 @@ $BT/apksigner verify 目标名.apk
窗口到期提示)、把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见)。 窗口到期提示)、把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见)。
初始状态**只更新断网角标**,恢复动作只在真正的 offline→online 跃迁时执行 初始状态**只更新断网角标**,恢复动作只在真正的 offline→online 跃迁时执行
(2026-09-26 由现场日志 `NET-UP` 出现在 `BOOT` 之前而发现)。 (2026-09-26 由现场日志 `NET-UP` 出现在 `BOOT` 之前而发现)。
- **⚠️ 歌曲源「授权窗口失效」的两种形态(都要走 `Eac19WindowGuard`)**:
额度**记在出口 IP 上、与设备无关**(实测换 mac/sn 无效)。
1. 旧形态:`do.php` 返回 403「检测到异常行为」;
2. **新形态(2026-10 实测)**:`do.php` 返回 **200,但对每一首歌(含不存在的编号如 1111111)
都返回同一个广告/测试资源** —— 实测 `https://wb66.cn/f/d/7b7c9/10188555.ts`。
这种"看似成功"的形态**不触发任何旧提示**,用户只看到「无法生成下载地址」,
表现就是**「歌曲源全部失效」**。`i.php` 仍返回 `code=200 / end_date=未来 / ordertype=0`,
`mwsLogin` 也返回 `authorized:true`(但 `license_box_count=0`/`license_duration=0`),
所以**别把它们当成"授权正常"**。
- 代码侧已处理:`SongUrlResolver` 统计"全部源只返回不可信地址"→ 打
`SOURCE-BLOCKED 全部 N 个源只返回广告/测试资源(该出口 IP 授权窗口失效)示例=<url>`
并调用 `Eac19WindowGuard.noteRiskBlocked()`(弹「歌曲源临时受限」引导重启光猫/路由器换 IP),
同时进入 3 分钟冷却(`BLOCKED_COOLDOWN_MS`)避免继续冲击源站;拿到真源立即解除。
- 现场处置:**更换公网出口 IP**(重启光猫/路由器重拨)即恢复;已下载的歌曲不受影响照常播。
- **403 也是受限信号**:2026-10 实测**主源 `w.w345.my/i.php` 直接返回 HTTP 403**、
镜像源返回 200 但只给固定广告文件 —— 两种形态会同时出现。因此
`KtvJsBridge.request` 的 403 判定已从"仅 `/music/do.php`"扩展到 **`/i.php` 也认**,
`SongUrlResolver` 也记录本轮是否见过 403(全部失败且见过 403 → 同样提示换 IP)。
- **旧的试用领取接口 `api.php` 已 404**(源站撤了),因此 App 无法自行"领取"试用窗口。
若换公网 IP 后仍只发广告,基本可判定为**服务/授权层面**问题(`i.php` 的
`ordertype=0`、`mwsLogin` 的 `license_box_count=0`/`license_duration=0` 都指向"无有效授权"),
需与歌曲源服务方确认续订,**不是客户端能修的**。
- ⚠️ 排查这类问题时**不要用生产身份/出口 IP 做密集探测**:源站按出口 IP 限流,
短时间内大量 `i.php`/`do.php`(多 host × 多参数组合)会加重受限,
把"能用"变成"只发广告"。少量、间隔式验证即可。
⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**: ⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**:
`SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与 `SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与
`_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上, `_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上,
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 33 versionCode 37
versionName "1.5.1" versionName "1.5.5"
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,否则
+8 -2
View File
@@ -12,7 +12,7 @@
(function(global) { (function(global) {
'use strict'; 'use strict';
// KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除) // KTV_BRIDGE_API: 11 (Android 缓存校验标记,请勿删除)
// ─── 配置 ────────────────────────────────────────── // ─── 配置 ──────────────────────────────────────────
const CONFIG = { const CONFIG = {
@@ -23,6 +23,12 @@
VER: '4', VER: '4',
// 歌曲源(均为同一后台的多个入口; 运行时按可用性自动排序, 解析失败的源沉底)。 // 歌曲源(均为同一后台的多个入口; 运行时按可用性自动排序, 解析失败的源沉底)。
HOSTS: [ HOSTS: [
// 2026-10 实测: 其余入口对每首歌统一返回广告/测试资源时,本入口仍稳定下发真源 CDN 地址,
// 故置于首位;它自己域名下的媒体始终是广告,由 AdVideoFilter 继续拦截。
'http://gz.ac16.vip',
// 与 w345.my 同一台服务器(154.219.102.181)的兄弟入口,登录层正常;
// 取址行为尚未定性 → 置于次位,由运行时自动升降序。
'http://a.ktv8.my',
'http://w.w345.my', 'http://w.w345.my',
'http://desktok.top', 'http://desktok.top',
'http://mm.kk456.top', 'http://mm.kk456.top',
@@ -441,7 +447,7 @@
// ─── 导出 ────────────────────────────────────────── // ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi; global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG; global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 9; global.KtvBridgeApiVersion = 11;
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
@@ -15,10 +15,15 @@ import android.util.Log
object AdVideoFilter { object AdVideoFilter {
private const val TAG = "AdVideoFilter" private const val TAG = "AdVideoFilter"
/** 命中这些片段的地址视为广告视频(匹配小写)。 */ /** 命中这些片段的地址视为广告/测试资源(匹配小写)。 */
private val AD_MARKERS = listOf( private val AD_MARKERS = listOf(
"/ad_files/", "/ad_files/",
"my_ad_video", "my_ad_video",
// 源站 2026-10 起"授权窗口失效"时会对每首歌返回同一个测试资源
// (实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。JS 的 isDemoUrl 早就把 wb66 当
// 测试资源,这里对齐,保证两条取址路径判定一致。
// ⚠️ 只收"wb66"这种高区分度标记:`demo` 之类太宽松的词会误伤真源 URL 里的随机 token。
"wb66",
) )
/** /**
@@ -3,15 +3,21 @@ package com.local.ktv
import android.util.Log import android.util.Log
/** /**
* 歌曲源(w.w345.my)"试用窗口"守卫(最小版)。 * 歌曲源"授权窗口"守卫。
* *
* 抓包/实测结论: 歌曲源对陌生出口 IP 发放约 10 分钟试用窗口 * 抓包/实测结论: 歌曲源对陌生出口 IP 发放约 10 分钟试用窗口
* (api.php: trial_times:2 × trial_duration:5), do.php 在该窗口外返回 * (api.php: trial_times:2 × trial_duration:5)。**额度记在出口 IP 上、与设备无关**
* 403"检测到异常行为", 换公网 IP 后立即恢复。额度记在出口 IP 上, * (实测换设备 mac/sn 无效、换公网 IP 立即恢复)。
* 与设备无关(同 IP 换 device 无效)。 *
* 窗口失效有**两种**表现,都必须走这里:
* 1. 旧形态:`do.php` 返回 403"检测到异常行为"(由 JS 桥调用 [noteRiskBlocked]);
* 2. 新形态(2026-10 实测):`do.php` 返回 **200,但对每一首歌(含不存在的编号)
* 都返回同一个广告/测试资源**(如 `https://wb66.cn/f/d/7b7c9/10188555.ts`)。
* 这种"看似成功"的形态以前不触发任何提示,用户只看到"无法生成下载地址" ——
* 现在由 `SongUrlResolver` 识别"全部源只返回广告/测试资源"后调用 [noteRiskBlocked]。
* *
* 职责: * 职责:
* - 收到 do.php 风控 403 信号时通知 UI 弹一次提示(引导重启光猫/路由器换 IP); * - 通知 UI 弹一次提示(引导重启光猫/路由器换 IP);
* - 取 URL 成功(窗口恢复)后自动复位, 下一次到期可再次提示; * - 取 URL 成功(窗口恢复)后自动复位, 下一次到期可再次提示;
* - 全程不阻塞、不影响播放(本地已缓存歌曲照常播)。 * - 全程不阻塞、不影响播放(本地已缓存歌曲照常播)。
*/ */
@@ -21,12 +27,22 @@ object Eac19WindowGuard {
/** UI 层订阅: 窗口到期(风控 403)时回调(已在主线程)。 */ /** UI 层订阅: 窗口到期(风控 403)时回调(已在主线程)。 */
@Volatile var onBlocked: (() -> Unit)? = null @Volatile var onBlocked: (() -> Unit)? = null
/**
* UI 层订阅:**所有源都取不到可用地址**时回调(已在主线程)。
*
* 与 [onBlocked](风控 403 / 只发广告)的区别:这里是"这一轮取址彻底失败"的总信号,
* 无论原因是 403、只返回广告、还是网络异常 —— 用户此时什么都点不了,
* 需要一句明确的话,而不是让界面看起来像没反应。
*/
@Volatile var onAllFailed: (() -> Unit)? = null
/** 距离上次提示的最小间隔, 防止连续失败时反复弹窗。 */ /** 距离上次提示的最小间隔, 防止连续失败时反复弹窗。 */
private const val MIN_PROMPT_INTERVAL_MS = 15 * 60_000L private const val MIN_PROMPT_INTERVAL_MS = 15 * 60_000L
private val lock = Any() private val lock = Any()
private var lastPromptAt = 0L private var lastPromptAt = 0L
private var blockedEpisodeActive = false private var blockedEpisodeActive = false
private var lastAllFailedAt = 0L
/** 由 JS 桥在 do.php 返回风控 403 时调用(任意线程)。 */ /** 由 JS 桥在 do.php 返回风控 403 时调用(任意线程)。 */
fun noteRiskBlocked() { fun noteRiskBlocked() {
@@ -48,6 +64,26 @@ object Eac19WindowGuard {
} }
} }
/**
* 由取址层在"**所有源都失败**"时调用(任意线程)。
* 与 [noteRiskBlocked] 同样按 15 分钟节流,避免每次点歌都弹。
*/
fun noteAllSourcesFailed() {
val now = System.currentTimeMillis()
val shouldNotify: Boolean
synchronized(lock) {
shouldNotify = now - lastAllFailedAt >= MIN_PROMPT_INTERVAL_MS
if (shouldNotify) lastAllFailedAt = now
}
if (shouldNotify) {
Log.w(TAG, "all song sources failed (no usable url)")
onAllFailed?.invoke()
}
}
/** 最近一次"全部源失效"提示的时间(0 = 从未);供 403 提示去重,避免同时弹两个框。 */
fun lastAllFailedAt(): Long = lastAllFailedAt
/** 取到有效歌曲 URL(窗口恢复)时调用, 复位本次到期 episode。 */ /** 取到有效歌曲 URL(窗口恢复)时调用, 复位本次到期 episode。 */
fun noteUrlOk() { fun noteUrlOk() {
synchronized(lock) { synchronized(lock) {
@@ -26,6 +26,13 @@ class KtvApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instanceRef = this instanceRef = this
// 远程日志尽早启动:以前只在 MainActivity.onCreate 里 init,
// 一旦更早的初始化抛异常就直接崩溃、**一条日志都没有**,现场无从排查。
// init 现在可重复调用且会自动重启发送线程,这里是"最靠前"的启动点。
runCatching {
MiliLog.init(this)
MiliLog.info("APP-INIT 进程启动 ver=${BuildConfig.VERSION_NAME} logFile=${MiliLog.localLogPath()}")
}.onFailure { Log.e(TAG, "MiliLog.init failed", it) }
OkDownload.setSingletonInstance(OkDownload.Builder(this).build()) OkDownload.setSingletonInstance(OkDownload.Builder(this).build())
Log.i(TAG, "KTV application initialized without native services") Log.i(TAG, "KTV application initialized without native services")
} }
@@ -335,7 +335,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: 9") it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 11")
} }
}.getOrDefault(false) }.getOrDefault(false)
@@ -361,7 +361,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: 9"), script.contains("KTV_BRIDGE_API: 11"),
) { "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)) {
@@ -486,7 +486,9 @@ class KtvJsBridge(context: Context) {
} }
// 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为", // 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为",
// 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。 // 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。
if (status == 403 && url.contains("/music/do.php")) { // ⚠️ 取 token 的 `/i.php` 同样会 403(2026-10 实测主源 w.w345.my/i.php 就是 403)——
// 以前只认 `/music/do.php`,全都 403 时用户看不到任何提示。
if (status == 403 && (url.contains("/i.php") || url.contains("/music/do.php"))) {
Eac19WindowGuard.noteRiskBlocked() Eac19WindowGuard.noteRiskBlocked()
} }
return raw return raw
@@ -524,18 +524,35 @@ class MainActivity : AppCompatActivity() {
Eac19WindowGuard.onBlocked = { Eac19WindowGuard.onBlocked = {
main.post { main.post {
if (isFinishing || isDestroyed) return@post if (isFinishing || isDestroyed) return@post
// 刚刚弹过"全部源失效"就不再叠加第二个框(两者常常同时触发)。
if (System.currentTimeMillis() - Eac19WindowGuard.lastAllFailedAt() < 60_000L) return@post
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("歌曲源临时受限") .setTitle("歌曲源临时受限")
.setMessage( .setMessage(
"歌曲源在当前网络(公网 IP)下暂时无法获取新歌播放地址(风控/试用窗口)。\n\n" + "歌曲源在当前网络(公网 IP)下暂时无法获取新歌播放地址。\n\n" +
"表现有两种:风控/试用窗口到期,或对每一首歌都只返回广告/测试资源" +
"(额度记在出口 IP 上,换设备无效)。\n\n" +
"解决办法:重启光猫/路由器重新拨号更换公网 IP,恢复后即可继续点歌。\n\n" + "解决办法:重启光猫/路由器重新拨号更换公网 IP,恢复后即可继续点歌。\n\n" +
"已缓存的歌曲不受影响,可正常播放。", "已缓存/已下载的歌曲不受影响,可正常播放。",
) )
.setPositiveButton("知道了", null) .setPositiveButton("知道了", null)
.create() .create()
.showForTv(DialogInterface.BUTTON_POSITIVE) .showForTv(DialogInterface.BUTTON_POSITIVE)
} }
} }
// 所有源都取不到地址(403 / 只发广告 / 网络异常都算):给用户一句明确的话。
// 以前这里只有"无法生成下载地址"的 toast,用户看着像没反应。
Eac19WindowGuard.onAllFailed = {
main.post {
if (isFinishing || isDestroyed) return@post
AlertDialog.Builder(this)
.setTitle("歌曲源不可用")
.setMessage("远程源已全部失效,等待新版本修复")
.setPositiveButton("知道了", null)
.create()
.showForTv(DialogInterface.BUTTON_POSITIVE)
}
}
// 初始化新布局的 UI 组件 // 初始化新布局的 UI 组件
initTvLayout() initTvLayout()
val startupSong = restoredState.currentSong ?: orderQueue!!.firstOrNull() val startupSong = restoredState.currentSong ?: orderQueue!!.firstOrNull()
@@ -5439,6 +5456,20 @@ class MainActivity : AppCompatActivity() {
if (::stateDatabase.isInitialized) runCatching { if (::stateDatabase.isInitialized) runCatching {
stateIo.execute { stateDatabase.updateDownload(song, "queued", 0) } stateIo.execute { stateDatabase.updateDownload(song, "queued", 0) }
} }
// ⚠️ 必须在**发起下载之前**就把任务挂进下载列表。
// 旧实现只在 onDownloadStart 里建任务,于是"取址失败"(无法生成下载地址)这类
// 还没真正开始下载的失败**不会留下任何记录** —— 用户点歌失败后下载列表空空如也,
// 既看不到原因也无法重试。现在先登记为「排队中」,任何失败都会就地改成「失败:原因」。
main.post(Runnable {
val task = downloads.firstOrNull { stableId(it.song) == stableId(song) }
?: DownloadTask(song).also { downloads.add(it) }
if (task.state == "等待" || task.state.isEmpty() || task.state.startsWith("失败")) {
task.state = "排队中"
}
task.cancelled = false
refreshSongAdapter()
if (currentTabIndex == 6) loadDownloadedList()
})
download(song, object : DownloadCallback { download(song, object : DownloadCallback {
override fun onDownloadStart(song: Song) { override fun onDownloadStart(song: Song) {
if (extraCallback != null) extraCallback.onDownloadStart(song) if (extraCallback != null) extraCallback.onDownloadStart(song)
@@ -6273,6 +6304,13 @@ class MainActivity : AppCompatActivity() {
confirmDeleteLocalFile(song) confirmDeleteLocalFile(song)
}, LinearLayout.LayoutParams(0, -2, 1f)) }, LinearLayout.LayoutParams(0, -2, 1f))
} }
// 下载列表里额外提供「移出列表」:在下载管理中删除条目(失败任务一直留在列表里,
// 就是为了能看清原因并自行清理或重试)。
if (browseMode == "downloads" && downloads.any { stableId(it.song) == stableId(song) }) {
actions.addView(action(R.drawable.ott_ic_delete, "移出列表") {
confirmRemoveDownloadEntry(song)
}, LinearLayout.LayoutParams(0, -2, 1f))
}
panel.addView(actions, LinearLayout.LayoutParams(-1, -2)) panel.addView(actions, LinearLayout.LayoutParams(-1, -2))
panel.addView(TextView(this).apply { panel.addView(TextView(this).apply {
text = song.singer.orEmpty() text = song.singer.orEmpty()
@@ -6307,6 +6345,41 @@ class MainActivity : AppCompatActivity() {
.create().showForTv() .create().showForTv()
} }
/** 下载列表「移出列表」确认框。 */
private fun confirmRemoveDownloadEntry(song: Song) {
AlertDialog.Builder(this)
.setTitle("移出下载列表")
.setMessage(
"确定把 ${song.title} 移出下载列表吗?\n" +
"只删除这条下载记录,不影响曲库;已下载的文件请用「删除文件」。",
)
.setNegativeButton("取消", null)
.setPositiveButton("确定") { _, _ -> removeDownloadEntry(song) }
.create().showForTv()
}
/**
* 把一首歌从下载列表中移出(同时清掉下载状态记录)。
*
* ⚠️ 只动"下载记录",**绝不删除任何歌曲文件** —— 文件删除是用户显式选择「删除文件」的事,
* 历史上"顺手清缓存"曾把整个公放曲库删光(1.4.10 事故)。
*/
private fun removeDownloadEntry(song: Song) {
val removing = downloads.filter { stableId(it.song) == stableId(song) }
if (removing.isEmpty()) {
toast("该歌曲不在下载列表中")
return
}
downloads.removeAll { stableId(it.song) == stableId(song) }
pendingQueueSongIds.remove(stableId(song))
if (::stateDatabase.isInitialized) runCatching {
stateIo.execute { stateDatabase.removeDownload(song) }
}
toast("已移出下载列表:${song.title}")
refreshSongAdapter()
if (browseMode == "downloads") loadDownloadedList()
}
private fun showSongInfo(song: Song) { private fun showSongInfo(song: Song) {
val message = ("歌名:" + song.title val message = ("歌名:" + song.title
+ "\n歌手:" + song.singer + "\n歌手:" + song.singer
@@ -9823,8 +9896,16 @@ class MainActivity : AppCompatActivity() {
onDownload = { song -> downloadSong(song) }, onDownload = { song -> downloadSong(song) },
onMore = { song -> showSongActions(song) }, onMore = { song -> showSongActions(song) },
onTop = { song -> moveQueuedSongToNext(song) }, onTop = { song -> moveQueuedSongToNext(song) },
// 下载列表里「删除」按状态区分语义:
// 有本地文件 → 删除文件(同时清掉下载记录)
// 失败/进行中(无文件)→ 移出下载列表(绝不误删别的文件)
// 其它列表(已点/已唱等)保持原语义:从队列移除。
onDelete = { song -> onDelete = { song ->
if (browseMode == "downloads") deleteDownloadedSong(song) else deleteQueuedSong(song) when {
browseMode != "downloads" -> deleteQueuedSong(song)
hasValidLocalSongFile(song) -> confirmDeleteLocalFile(song)
else -> confirmRemoveDownloadEntry(song)
}
}, },
onPauseResume = { song -> toggleDownloadPause(song) }, onPauseResume = { song -> toggleDownloadPause(song) },
), ),
+55 -1
View File
@@ -55,12 +55,20 @@ object MiliLog {
private const val RETRY_SLEEP_MS = 1500L private const val RETRY_SLEEP_MS = 1500L
private const val FAILURE_BACKOFF_MS = 10_000L private const val FAILURE_BACKOFF_MS = 10_000L
/** 本地日志文件:远程日志一旦不可用(网络/服务端问题),现场仍能取证。 */
private const val LOCAL_LOG_DIR = "logs"
private const val LOCAL_LOG_NAME = "ktv.log"
private const val LOCAL_LOG_MAX_BYTES = 1024L * 1024L
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private val publicIpFetching = AtomicBoolean(false) private val publicIpFetching = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE) private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
private var senderThread: Thread? = null private var senderThread: Thread? = null
/** 本地日志文件(后台线程写入,绝不占用主线程 I/O)。 */
@Volatile private var localLogFile: java.io.File? = null
@Volatile private var serverName: String = "android" @Volatile private var serverName: String = "android"
/** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */ /** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */
@@ -74,13 +82,50 @@ object MiliLog {
/** 用 app context 初始化并启动发送线程。可重复调用。 */ /** 用 app context 初始化并启动发送线程。可重复调用。 */
fun init(context: Context) { fun init(context: Context) {
if (started.getAndSet(true)) return // 首次初始化时准备本地日志文件。
if (localLogFile == null) {
runCatching {
val dir = java.io.File(AppPaths.root, LOCAL_LOG_DIR)
if (dir.exists() || dir.mkdirs()) localLogFile = java.io.File(dir, LOCAL_LOG_NAME)
}
}
if (!started.getAndSet(true)) {
serverName = sanitize(Build.MODEL).ifEmpty { "android" } serverName = sanitize(Build.MODEL).ifEmpty { "android" }
}
// ⚠️ 只要**没有存活的发送线程**就(重新)启动它。
// 旧实现是 `if (started.getAndSet(true)) return`:一旦发送线程因任何原因退出
// (例如有人调用过 [shutdown],或线程意外终止),后续 init 全是空操作,
// 日志只入队、永远不再发送 —— 现场表现就是「远程日志失效」,而且无从察觉。
val alive = senderThread?.isAlive == true
if (running.get() && alive) {
ensureServerIp()
return
}
running.set(true) running.set(true)
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() } senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
Log.i(TAG, "glog sender (re)started")
ensureServerIp() ensureServerIp()
} }
/** 本地日志文件(供现场取证);未初始化时返回 null。 */
fun localLogPath(): String? = localLogFile?.absolutePath
/**
* 追加一行到本地日志(**由发送线程调用**,不在调用方线程做磁盘 I/O)。
* 超过 [LOCAL_LOG_MAX_BYTES] 就滚动一次,保证文件不会无限增长。
*/
private fun appendLocal(line: String) {
val file = localLogFile ?: return
runCatching {
if (file.length() > LOCAL_LOG_MAX_BYTES) {
val prev = java.io.File(file.parentFile, "$LOCAL_LOG_NAME.prev")
prev.delete()
file.renameTo(prev)
}
file.appendText(line + "\n", Charsets.UTF_8)
}
}
/** /**
* 更新上报用的客户端网络/身份字段。 * 更新上报用的客户端网络/身份字段。
* *
@@ -232,11 +277,14 @@ object MiliLog {
private fun sendLoop() { private fun sendLoop() {
var consecutiveFailures = 0 var consecutiveFailures = 0
while (running.get()) { while (running.get()) {
try {
val body: String = try { val body: String = try {
queue.take() queue.take()
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
break break
} }
// 本地留一份(远程日志不可用时,现场仍可取证)。
appendLocal(body)
var sent = post(body) var sent = post(body)
if (!sent) { if (!sent) {
sleepQuietly(RETRY_SLEEP_MS) sleepQuietly(RETRY_SLEEP_MS)
@@ -247,6 +295,7 @@ object MiliLog {
// 顺手把已积压的日志发完。 // 顺手把已积压的日志发完。
var next: String? = queue.poll() var next: String? = queue.poll()
while (next != null) { while (next != null) {
appendLocal(next)
post(next) post(next)
next = queue.poll() next = queue.poll()
} }
@@ -258,6 +307,11 @@ object MiliLog {
sleepQuietly(FAILURE_BACKOFF_MS) sleepQuietly(FAILURE_BACKOFF_MS)
} }
} }
} catch (t: Throwable) {
// 兜底:发送线程**绝不能因为任何异常退出**,否则远程日志会静默失效。
Log.w(TAG, "glog loop error: ${t.javaClass.simpleName}: ${t.message}")
sleepQuietly(1000L)
}
} }
} }
@@ -135,7 +135,7 @@ class SongListAdapter(
if (!state.local || state.queued) { if (!state.local || state.queued) {
addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40))) addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40)))
} }
addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40))) addView(icon(R.drawable.ott_ic_more) { callbacks.onMore(song) }, LinearLayout.LayoutParams(dp(40), dp(40)))
configureSongCard(this, song, songIndex) configureSongCard(this, song, songIndex)
if (songIndex >= columns) nextFocusUpId = cardId(songIndex - columns) if (songIndex >= columns) nextFocusUpId = cardId(songIndex - columns)
if (column == 1) nextFocusLeftId = cardId(songIndex - 1) if (column == 1) nextFocusLeftId = cardId(songIndex - 1)
@@ -167,7 +167,7 @@ class SongListAdapter(
if (!state.local || state.queued) { if (!state.local || state.queued) {
addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40))) addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40)))
} }
addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40))) addView(icon(R.drawable.ott_ic_more) { callbacks.onMore(song) }, LinearLayout.LayoutParams(dp(40), dp(40)))
configureSongCard(this, song, index) configureSongCard(this, song, index)
} }
} }
@@ -38,8 +38,19 @@ object SongUrlResolver {
private const val VER = "2.0" private const val VER = "2.0"
private const val VN = "4.1.3.03281430" private const val VN = "4.1.3.03281430"
private const val TIMEOUT_MS = 8_000 private const val TIMEOUT_MS = 8_000
/**
* "源站只发广告/测试资源"后的冷却时长。
*
* 该限制记在**出口 IP** 上、与设备无关(实测换 mac/sn 无效),源站侧是约 10 分钟的试用窗口;
* 这里冷却 3 分钟即可(够避免继续冲击源站,又不会拖太久错过窗口恢复)。
*/
private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L
private val DEFAULT_HOSTS = listOf( private val DEFAULT_HOSTS = listOf(
// 与其他入口同一后台、同一协议;2026-10 实测唯一仍下发真源 CDN 地址的入口,放首位。
"http://gz.ac16.vip",
// 与 w345.my 同机(154.219.102.181)的兄弟入口;实测登录层正常,取址行为待观察。
"http://a.ktv8.my",
"http://w.w345.my", "http://w.w345.my",
"http://desktok.top", "http://desktok.top",
"http://mm.kk456.top", "http://mm.kk456.top",
@@ -56,6 +67,12 @@ object SongUrlResolver {
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN @Volatile private var sn: String = KtvJsBridge.DEVICE_SN
/** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */
@Volatile private var sawForbidden = false
/** 被判定为"源站只发广告/测试资源"后的冷却截止时间(0 = 未受限)。 */
@Volatile private var blockedUntil: Long = 0L
/** /**
* 最近一次取址命中的源 host。 * 最近一次取址命中的源 host。
* 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的 * 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的
@@ -102,10 +119,23 @@ object SongUrlResolver {
MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底") MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
return null return null
} }
// 已判定"源站对该出口 IP 只发广告/测试资源"时进入冷却:直接失败,
// 既让用户立刻看到结论,也避免继续冲击源站把限流拖得更久。
val nowMs = System.currentTimeMillis()
if (nowMs < blockedUntil) {
MiliLog.warn(
"SOURCE-BLOCKED 冷却中(剩 ${(blockedUntil - nowMs) / 1000}s)跳过取址 编号=$musicno",
)
return null
}
sawForbidden = false
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn) val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
val startedAt = System.currentTimeMillis() val startedAt = System.currentTimeMillis()
val hosts = synchronized(hostOrder) { hostOrder.toList() } val hosts = synchronized(hostOrder) { hostOrder.toList() }
var lastDetail = "no-source" var lastDetail = "no-source"
var adOnlyCount = 0 // 返回了地址但被判为广告/测试资源的源数
var otherFailCount = 0 // token 失败 / 无地址 / 网络异常的源数
var adSample = ""
for ((index, host) in hosts.withIndex()) { for ((index, host) in hosts.withIndex()) {
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse { val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
@@ -114,6 +144,7 @@ object SongUrlResolver {
} }
if (token.isNullOrEmpty()) { if (token.isNullOrEmpty()) {
lastDetail = "token-fail" lastDetail = "token-fail"
otherFailCount++
demote(host) demote(host)
continue continue
} }
@@ -125,6 +156,7 @@ object SongUrlResolver {
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) { if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
promote(host) promote(host)
lastHost = host lastHost = host
blockedUntil = 0L // 拿到真源 → 立刻解除冷却
val elapsed = System.currentTimeMillis() - startedAt val elapsed = System.currentTimeMillis() - startedAt
val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?" val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?"
MiliLog.info( MiliLog.info(
@@ -137,11 +169,41 @@ object SongUrlResolver {
url.isNullOrEmpty() -> "no-url" url.isNullOrEmpty() -> "no-url"
else -> "demo-or-mismatch" else -> "demo-or-mismatch"
} }
if (url.isNullOrEmpty()) {
otherFailCount++
} else {
adOnlyCount++
if (adSample.isEmpty()) adSample = url
}
demote(host) demote(host)
} }
val elapsed = System.currentTimeMillis() - startedAt val elapsed = System.currentTimeMillis() - startedAt
MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms") // ⚠️ 源站 2026-10 起的新受限形态:不再返回 403,而是**对每一首歌(含不存在的编号)
// 都返回同一个广告/测试资源**(实测 https://wb66.cn/f/d/7b7c9/10188555.ts)。
// 额度记在**出口 IP** 上,与设备身份无关(换 mac/sn 无效)—— 表现就是"歌曲源全部失效"。
// 这里把它识别出来并接入 Eac19WindowGuard 的提示(引导重启光猫/路由器换公网 IP)。
if (adOnlyCount > 0 && otherFailCount == 0) {
blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS
MiliLog.error(
"SOURCE-BLOCKED 全部 $adOnlyCount 个源只返回广告/测试资源(该出口 IP 授权窗口失效)" +
" 编号=$musicno 示例=$adSample 用时=${elapsed}ms",
)
Eac19WindowGuard.noteRiskBlocked()
} else {
MiliLog.error(
"NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms saw403=$sawForbidden",
)
// 全都 403 = 源站明确风控(另一种受限形态,与"只发广告"并列),同样引导换公网 IP。
if (sawForbidden) {
blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS
MiliLog.error("SOURCE-BLOCKED 源站返回 HTTP 403(风控/授权窗口失效)编号=$musicno")
Eac19WindowGuard.noteRiskBlocked()
}
}
// 本轮取址彻底失败(无论原因是 403、只返回广告、还是网络异常):
// 发出"全部源失效"总信号,让界面明确告知用户,而不是看起来没反应。
Eac19WindowGuard.noteAllSourcesFailed()
return null return null
} }
@@ -188,6 +250,9 @@ object SongUrlResolver {
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val status = envelope.optInt("status") val status = envelope.optInt("status")
if (status !in 200..299) { if (status !in 200..299) {
// 403 = 源站风控的直接信号(2026-10 实测主源 w.w345.my/i.php 就是 403)。
// 记下来,全部源都失败时据此走 Eac19WindowGuard 的"换公网 IP"提示。
if (status == 403) sawForbidden = true
MiliLog.warn( MiliLog.warn(
"NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " + "NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " +
"err=${envelope.optString("error").take(80)}", "err=${envelope.optString("error").take(80)}",
@@ -63,4 +63,18 @@ public final class AdVideoFilterTest {
public void allowsNonTsUrlWithoutAdMarkers() { public void allowsNonTsUrlWithoutAdMarkers() {
assertTrue(AdVideoFilter.isTrustedSongUrl("http://cdn.example.com/path/stream?token=abc", "7303235")); assertTrue(AdVideoFilter.isTrustedSongUrl("http://cdn.example.com/path/stream?token=abc", "7303235"));
} }
/**
* 2026-10 实测:源站授权窗口失效时**不再返回 403**,而是对每一首歌(含不存在的编号,
* 如 1111111/9999999)都返回同一个测试资源 `https://wb66.cn/f/d/7b7c9/10188555.ts`。
* 这就是用户报的「歌曲源全部失效」的真实来源,必须拦下(否则广告会被当成歌曲下载播放)。
*/
@Test
public void rejectsDemoResourceFromExhaustedWindow() {
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/10188555.ts", "7303235"));
// 即使该资源被伪装成"正好等于点歌编号",wb66 域名特征也必须拦下
assertFalse(AdVideoFilter.isTrustedSongUrl(
"https://wb66.cn/f/d/7b7c9/7303235.ts", "7303235"));
}
} }