Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40a2bdf9b | ||
|
|
fad2599e63 | ||
|
|
eadc2d4de1 | ||
|
|
669766ba60 | ||
|
|
b3b17210f1 | ||
|
|
906fa210e4 |
@@ -19,8 +19,13 @@
|
||||
把积压的 commit 一并推上即可。
|
||||
2. **Gitee 不再同步代码,只发布版本**:Gitee 仓库保持「仅 README 文档 + Releases 附件」状态,
|
||||
**禁止推送源码**(`pre-push` 钩子会拦截 `gitee.com`)。Gitee 上只做一件事:创建 Release 并上传 APK 附件。
|
||||
3. **每次更新版本号 +1**:完成一次修改就把版本号递增(`versionName` 与 `versionCode` 同步递增),
|
||||
例如 `1.3.0` → `1.3.1` → `1.3.2`。不要在同一个版本号下反复改动交付。
|
||||
3. **版本号递增规则(2026-09-26 更新)**:`versionName` 形如 `<major>.<minor>.<patch>`,
|
||||
**patch 取值 0~10、minor 取值 1~10**,每次修改先用掉一个 patch:
|
||||
- 常规递增:`1.5.0` → `1.5.1` → … → `1.5.10`;
|
||||
- patch 到 10 后再改 → **进位到 minor、patch 归 0**:`1.4.10` → `1.5.0`;
|
||||
- minor 到 10 后再进位 → **major +1、minor 归 1、patch 归 0**:`1.10.1` → `2.1.0`。
|
||||
`versionCode` 始终同步 +1(单调递增,供系统与更新检查判断新旧)。
|
||||
不要在同一个版本号下反复改动交付。
|
||||
4. **发布是手动操作**:**未收到明确的「发布新版本」指令时,默认只在本地打包测试**——
|
||||
产物按内部测试约定命名 `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`,
|
||||
**不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release**。
|
||||
@@ -160,7 +165,7 @@ $BT/apksigner verify 目标名.apk
|
||||
|
||||
### 版本号
|
||||
|
||||
- `app/build.gradle`:`versionName "1.4.7"` / `versionCode 27`(18~21 被本机临时诊断包占用过)
|
||||
- `app/build.gradle`:`versionName "1.5.1"` / `versionCode 33`(18~21 被本机临时诊断包占用过)
|
||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
|
||||
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
|
||||
@@ -273,6 +278,46 @@ $BT/apksigner verify 目标名.apk
|
||||
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch`;
|
||||
桥侧另有 `JS-BRIDGE ...`(ready / KtvApi 未导出 / WebView 初始化失败 / 15s 未就绪)。
|
||||
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
|
||||
- **⚠️ 广告/错号地址只有一道闸门:`AdVideoFilter.isTrustedSongUrl(url, musicno)`**
|
||||
(2026-09-26 事故:新设备点歌 7303235 拿到 `gz.ac16.vip` 的**广告视频并被当成歌曲下载播放**)。
|
||||
约定:
|
||||
1. **任何来源**的播放地址都必须过这道闸门 —— JS 取址(`ktv_api.js` 的 `isDemoUrl`)、
|
||||
原生兜底(`SongUrlResolver.isValidSongUrl`)、DB 回退(`SongApiClient.fetchFromDb`),
|
||||
以及**最终兜底** `SongOkDownloadManager.buildDownloadUrl` 的成功分支。
|
||||
只加在某一层必然漏:这次就是 JS 层的规则不认广告域名、且 Kotlin 的成功分支**完全没做校验**;
|
||||
2. 判定三条:非 http(s);命中广告特征(路径 `/ad_files/`、`my_ad_video`、**域名 `*.ac16.vip`**);
|
||||
**URL 里的 `.ts` 文件名必须等于点歌编号**(真源恒为 `/<musicno>.ts`,已实测
|
||||
`txog.ktvsky.com` / `download.origjoy.com` 两个 CDN 域)。旧规则只比对"纯数字文件名",
|
||||
`my_ad_video.ts` 这类非数字名字直接漏过;
|
||||
3. **内容校验挡不住广告**:广告视频能通过 `SongFileValidator`(≥6MB / ≥60s / TS 包完整),
|
||||
所以"取址即校验"是唯一有效防线。一旦判定地址不可信,必须**同时删除已落盘的歌曲文件**
|
||||
(`purgeCachedSongFile`),否则 `isDownloaded()` 会一直复用那个广告文件,广告反复播;
|
||||
4. 历史遗留由 `SongOkDownloadManager.purgeInjectedAdDuplicatesOnce()` 在升级后清一次
|
||||
(marker:`filesDir/ad_dup_purge_v1`),**只删「多首不同歌曲内容完全相同」的文件**:
|
||||
广告对每首歌都下发同一个视频,所以它的特征是"多首歌共用同一段视频";
|
||||
**绝不能图省事直接清空整个歌曲目录**(1.4.10 这么干过,见下条);
|
||||
5. 回归测试 `app/src/test/java/com/local/ktv/AdVideoFilterTest.java` 用日志里的**真实 URL 形态**
|
||||
锁住行为(含两个真源 CDN 域),改这块逻辑必须先让它通过。
|
||||
- ⚠️ **文件名比对只在编号可信时启用**:`buildDownloadUrl` 里只有"从 `song.filename` 推出"的
|
||||
点歌编号才传给 `isTrustedSongUrl`;`song.id` 可能是数据库主键,拿它比对会把**正常地址误杀**
|
||||
(表现为真源被判"文件名不符"→ 永远下不到歌)。
|
||||
- **⚠️ 本地歌曲缓存 = 公放曲库,绝不能整目录清空**(2026-09-26 新设备回归):
|
||||
`MuseDatabase.songDirectories()` **只有 `cloudSongsDir` 一个目录**,
|
||||
`SongOkDownloadManager.getLocalFile()` 也落在那里 —— 所以"清理歌曲缓存"就等于**删掉公放曲库**。
|
||||
1.4.10 在启动时做过一次整目录清理,结果:库里没歌 → `playRandomLocalSong()` 选不出候选
|
||||
→ `currentSong` 保持 null → 按「切歌」时 `advancePlayback` 落进
|
||||
「取消随机续播、进入空闲态」分支 → **按钮按了完全没反应**(用户报「公放时切歌失效」)。
|
||||
约定:**任何"清理缓存"的改动都必须先确认它会删掉哪些目录**;要清理就只删**能证明有问题**的
|
||||
文件(见上条),**永远不要动用户已下载的正常歌曲**。
|
||||
- **⚠️ 主线程禁止网络 I/O**:`SongApiClient.getSongDownloadUrl` 的主线程分支只允许走
|
||||
本地 DB 兜底(`fetchFromDb`)。1.4.7 曾在该分支先调 `resolveNatively()` ——
|
||||
原生兜底最多 7 个源 × 8 秒超时,一旦有主线程调用者就会**卡死 UI 到 ANR**。
|
||||
取址(JS 桥 / 原生兜底)一律只允许在后台线程发起。
|
||||
- **⚠️ 裸 Socket 的 HTTPS 必须带主机名**:用 `TlsCompat.sslSocketFactory()` 时必须
|
||||
`createSocket(plainSocket, host, port, true)`(**先连接、再按主机名包装**),
|
||||
不能用无参 `createSocket()` —— 后者拿到的 `SSLSocket` **不带主机名**,
|
||||
会被 SNI 与证书主机名校验影响(共享 CDN / 反代多证书时直接握手失败)。
|
||||
这样既保住显式连接超时,又补上 SNI。
|
||||
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
|
||||
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
|
||||
- **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
|
||||
@@ -297,6 +342,23 @@ $BT/apksigner verify 目标名.apk
|
||||
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
|
||||
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
|
||||
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
|
||||
- **写日志的五条约定**(2026-09-26 依据现场日志复盘补充):
|
||||
1. **持久状态只记一次**,不要每次事件都记。反例:桥不可用曾按「每次点歌一条 WARN」输出,
|
||||
一台机器一天几百条、把日志刷满且毫无新增信息。现在由 `KtvJsBridge.markUnavailable()`
|
||||
在**状态首次变化**时记一条。
|
||||
2. **关键行必须带全上下文**:`STORAGE`/`BOOT` 这类启动行要放在 `reportDeviceBootInfo()` 的
|
||||
后台线程里、`refreshLogDeviceContext()` 与 `MiliLog.awaitServerIp()` **之后**,
|
||||
否则 `serverip`/`clientip`/`user`/`traceid` 全是 `undefined`(旧日志按设备检索不到)。
|
||||
3. **不要在日志行里做重活**:`AppPaths.storageFacts()`/`diagnose()` 会真实建目录、写探针、
|
||||
读 `/proc/mounts`,**绝不能放在主线程**(启动时曾如此)。
|
||||
4. **日志要能自我定位**:失败行写清"哪一步/哪个源/耗时/原因",例如
|
||||
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch 用时=...ms`;
|
||||
桥侧由 `JS-BRIDGE ...` 说明 WebView 到底卡在哪一步(初始化失败/KtvApi 未导出/20s 超时)。
|
||||
5. **状态样本 + 事件**缺一不可:事件(点歌/失败)说明"发生了什么",但排查"随时间变化"的
|
||||
问题(空间变少、曲库丢失、长期离线)需要状态样本 —— 现由 6 小时一条 `HEALTH ...`
|
||||
(版本/在线/可用空间/曲库大小/根目录)提供。
|
||||
- 一段典型现场日志应为:`BOOT` → `STORAGE` → (点歌)`点歌编号=... 请求源=... 状态=成功 urlHost=...`
|
||||
→ `PLAY ... src=local` → 每 6h `HEALTH ...`;异常时才有 `RAW-ERR`/`NET-ERR`/`NATIVE-SONG ...失败`。
|
||||
- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询)
|
||||
维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责:
|
||||
- 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、
|
||||
@@ -304,6 +366,13 @@ $BT/apksigner verify 目标名.apk
|
||||
- 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、
|
||||
复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。
|
||||
换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。
|
||||
- ⚠️ **启动 ≠ 网络恢复**:`KtvNetworkMonitor.start()` 的回调带 **`initial`** 参数,
|
||||
`initial=true` 表示"注册回调时系统回报的初始状态"。**绝不能**把它当成"断网后恢复"——
|
||||
启动流程(`onCreate`)自己已经做过 `startRemoteServer()`/二维码/设备上下文,
|
||||
再走一遍恢复逻辑会:白清刚建立的歌曲源会话、语义错误地 `noteUrlOk()`(掩盖真正的
|
||||
窗口到期提示)、把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见)。
|
||||
初始状态**只更新断网角标**,恢复动作只在真正的 offline→online 跃迁时执行
|
||||
(2026-09-26 由现场日志 `NET-UP` 出现在 `BOOT` 之前而发现)。
|
||||
⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**:
|
||||
`SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与
|
||||
`_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上,
|
||||
@@ -384,6 +453,42 @@ $BT/apksigner verify 目标名.apk
|
||||
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
|
||||
**不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、
|
||||
**App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘)
|
||||
- **⚠️ 切歌/续播的状态机(`MainActivity.advancePlayback`)**:`orderQueue` 是"已点清单",
|
||||
正在播的那首**仍在队列里**,切歌时才移除。四条出口,改动前先对齐语义:
|
||||
1. **显式切歌**且队列还有歌 → 播下一首;
|
||||
2. **自然播完** + `autoNext` + 队列已空 → 进公放(随机本地歌曲);
|
||||
3. **显式切歌**且队列已空(= 在点歌清单**最后一首**上按切歌)→ **进公放**。
|
||||
这是 2026-09-26 用户明确要求的行为:公放清单与点歌清单互不影响
|
||||
(随机歌曲不入 `orderQueue`;用户一点歌,挂起的随机请求就会被
|
||||
`orderQueue.isNotEmpty()` 守卫取消);
|
||||
4. 手动模式(`autoNext=false`)自然播完 → 停在空闲态等待点歌(保持旧逻辑)。
|
||||
⚠️ **凡是要清空 `currentSong`/播放状态的出口,都必须同时停播放器**
|
||||
(`player.stopPlayback()` + `currentMediaPlayer = null`,参考「清空已点」处理)。
|
||||
原因:`onCompletion` 第一行是
|
||||
`if (generation != playbackGeneration || currentSong?.equals(song) != true) return`,
|
||||
所以只要出现"**播放器还在跑、`currentSong` 已被置空**",那首歌播完时回调被直接吞掉:
|
||||
不再推进、状态却是"无歌可播",之后按切歌/播放都没有反应 ——
|
||||
2026-09-26「点歌最后一首按切歌 → 播完卡死」就是这个僵尸态。
|
||||
唯一例外是"马上要 `play()` 下一首"的路径(可以不停,避免切歌瞬间静音)。
|
||||
- **⚠️ 「切歌 → 公放」在老盒子上卡死(2026-09-26,极米 H1S)**:新设备正常、老设备卡死,
|
||||
排查出**三个叠加原因**,都已修掉,改动这块时必须同时守住:
|
||||
1. **不能共用 `io` 线程池**:`io` 只有 4 个线程,且"点歌即下即播"(`streamDownloadAndPlay`)
|
||||
这类几十秒的长任务也在里面。老设备网速慢,并发点几首歌就把池占满,切歌任务一直排队。
|
||||
→ 切歌/随机续播改用 `randomPlayExecutor`(专用单线程守护线程)。
|
||||
2. **不能现扫目录找候选**:候选来自 `localSongInventory()` + `library.scanLocal()`,
|
||||
代价是"每个文件在头尾各读约 4.3MB 做 TS 校验" + "遍历 `/storage/ 各挂载点`
|
||||
(老 ROM 的失效挂载点会让 `File.listFiles()` **永久阻塞**)"。
|
||||
→ 改为启动时后台**预热候选缓存**(`primeRandomCandidates()`:独立守护线程 +
|
||||
`scanWithTimeout` 超时保护),下载完成即时补进缓存,切歌直接读缓存(零 I/O)。
|
||||
3. **主线程不能做深度文件校验**:`Song.hasLocalFile()` 内部是 `SongFileValidator.inspect`
|
||||
(头尾各读 4.3MB)。它曾被用在 `play()`、播放错误回调、`switchToLocalPlayback`,
|
||||
以及**逐行渲染**的 `songRowState()` 里 —— 慢存储上每次都是秒级,直接 ANR。
|
||||
→ 播放/渲染路径统一改用**廉价检查** `MainActivity.hasPlayableLocalFile()`
|
||||
(只看存在与大小),深度校验只留在后台(`localSongInventory()` / `isDownloaded()`)。
|
||||
- **⚠️ Kotlin 块注释会嵌套,注释里不能出现 `/*`**:写 `` `/storage/*` `` 时那个 `/*` 会开启一个
|
||||
**嵌套注释**,导致 KDoc 永远不闭合,后面的代码全被吞进注释里(报错是
|
||||
"Unclosed comment" + 一堆 "Missing '}'",很难一眼看出)。注释里要写路径通配就写
|
||||
`/storage/ 各挂载点` 这种不含 `/*` 的说法。
|
||||
- **弹窗分辨率自适应**:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080,
|
||||
density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 `fitDialogToScreen(dialog, scroll,
|
||||
content, preferredWidthDp, …)`:宽度取 `min(preferredWidthDp, 屏幕宽*0.94)`,高度把内容所在的
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 27
|
||||
versionName "1.4.7"
|
||||
versionCode 33
|
||||
versionName "1.5.1"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
(function(global) {
|
||||
'use strict';
|
||||
|
||||
// KTV_BRIDGE_API: 8 (Android 缓存校验标记,请勿删除)
|
||||
// KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除)
|
||||
|
||||
// ─── 配置 ──────────────────────────────────────────
|
||||
const CONFIG = {
|
||||
@@ -138,13 +138,19 @@
|
||||
function isDemoUrl(url, musicno) {
|
||||
var s = String(url || '');
|
||||
if (/(?:wb66|demo)/i.test(s)) 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;
|
||||
// R2 demo bucket: pub-*.r2.dev/ss/ 通用测试资源
|
||||
if (/pub-[a-z0-9]+\.r2\.dev\/ss\//i.test(s)) return true;
|
||||
// ts 文件名是纯数字, 若与请求的 musicno 不一致则视为广告/测试资源。
|
||||
// 原版真源 URL 的 ts 文件名总是等于 musicno (如 /7120742.ts)。
|
||||
// ts 文件名必须等于 musicno。原版真源 URL 恒为 /<musicno>.ts(已实测两个 CDN 域
|
||||
// txog.ktvsky.com / download.origjoy.com 的 6 首歌全部如此)。
|
||||
// ⚠️ 旧规则只比对"纯数字文件名"(/\/(\d+)\.ts/),像 my_ad_video.ts 这种非数字名字
|
||||
// 会直接漏过 —— 这正是广告漏放的直接原因之一。
|
||||
if (musicno) {
|
||||
var m = s.match(/\/(\d+)\.ts(?:\?|$)/);
|
||||
var m = s.match(/\/([^\/?]+)\.ts(?:\?|$)/i);
|
||||
if (m && m[1] !== String(musicno)) return true;
|
||||
}
|
||||
return false;
|
||||
@@ -435,7 +441,7 @@
|
||||
// ─── 导出 ──────────────────────────────────────────
|
||||
global.KtvApi = KtvApi;
|
||||
global.KtvApiConfig = CONFIG;
|
||||
global.KtvBridgeApiVersion = 8;
|
||||
global.KtvBridgeApiVersion = 9;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
|
||||
|
||||
@@ -21,6 +21,19 @@ object AdVideoFilter {
|
||||
"my_ad_video",
|
||||
)
|
||||
|
||||
/**
|
||||
* 广告视频专用域名(命中即判定为广告,含其子域)。
|
||||
*
|
||||
* 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235 拿到了
|
||||
* `gz.ac16.vip` 的广告视频,而且它**通过了内容校验**(≥6MB / ≥60s / TS 包完整),
|
||||
* 于是被当歌曲缓存并播放。仅靠路径片段(`/ad_files/`、`my_ad_video`)不足以拦住 ——
|
||||
* 广告方一改文件名就漏,因此必须**按域名**兜底。
|
||||
*/
|
||||
private val AD_HOSTS = listOf(
|
||||
"gz.ac16.vip",
|
||||
"ac16.vip",
|
||||
)
|
||||
|
||||
/**
|
||||
* 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底);
|
||||
* 设置后,命中广告时用该地址替换原广告。
|
||||
@@ -29,14 +42,43 @@ object AdVideoFilter {
|
||||
@JvmField
|
||||
var replacementUrl: String? = null
|
||||
|
||||
/** 判断是否为广告视频地址。 */
|
||||
/** 判断是否为广告视频地址(路径特征 + 域名)。 */
|
||||
@JvmStatic
|
||||
fun isAdVideo(url: String?): Boolean {
|
||||
if (url.isNullOrEmpty()) return false
|
||||
val lower = url.lowercase()
|
||||
return AD_MARKERS.any { lower.contains(it) }
|
||||
if (AD_MARKERS.any { lower.contains(it) }) return true
|
||||
val host = hostOf(url) ?: return false
|
||||
return AD_HOSTS.any { host == it || host.endsWith(".$it") }
|
||||
}
|
||||
|
||||
/**
|
||||
* 取址链路的**统一可信校验**(所有来源的地址都必须过这一关)。
|
||||
*
|
||||
* 判定为不可信的情况:
|
||||
* 1. 非 http(s);
|
||||
* 2. 命中广告特征([isAdVideo]);
|
||||
* 3. **URL 里的 `.ts` 文件名与点歌编号不一致** —— 真源 URL 的文件名恒等于点歌编号
|
||||
* (见 `ktv_api.js` 的说明),而广告文件常叫 `my_ad_video.ts`。
|
||||
* 旧规则只处理"纯数字且与编号不同"(`/\/(\d+)\.ts/`),非数字文件名直接放行,
|
||||
* 这正是本次广告漏放的直接原因。
|
||||
*/
|
||||
@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)
|
||||
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()
|
||||
|
||||
/** 取 `.ts` 的**文件名主体**(不含扩展名),供与点歌编号比对。 */
|
||||
private val TS_BASENAME = Regex("/([^/?]+)\\.ts(?:\\?|$)", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换);
|
||||
* 非广告地址原样返回。
|
||||
|
||||
@@ -45,6 +45,8 @@ class KtvJsBridge(context: Context) {
|
||||
"https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
|
||||
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
private const val JS_REQUEST_TIMEOUT_MS = 180_000
|
||||
/** WebView 初始化看门狗:超过这个时间还没就绪就判定本机 WebView 不可用。 */
|
||||
private const val BRIDGE_INIT_TIMEOUT_MS = 20_000L
|
||||
// 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
|
||||
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
|
||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||
@@ -75,6 +77,17 @@ class KtvJsBridge(context: Context) {
|
||||
@Volatile private var webView: WebView? = null
|
||||
@Volatile private var ready = false
|
||||
@Volatile private var destroyed = false
|
||||
|
||||
/**
|
||||
* 已判定「本机 WebView 不可用」。
|
||||
*
|
||||
* 一旦置位,`SongApiClient` 就不再为取址白等 15 秒,直接走原生通道
|
||||
* (极米 H1S 实测每次点歌都要等满 15 秒,日志里表现为每首歌前一条
|
||||
* 「JS-BRIDGE 15s 未就绪」)。桥万一后来就绪(热更新重载等)会自动清除。
|
||||
*/
|
||||
@Volatile private var unavailable = false
|
||||
private var unavailableReason: String = ""
|
||||
|
||||
private var loadedFromCache = false
|
||||
private var assetRetryAttempted = false
|
||||
|
||||
@@ -96,9 +109,17 @@ class KtvJsBridge(context: Context) {
|
||||
loadedFromCache = cachedScript != null
|
||||
createWebView(cachedScript ?: readAssetScript(), callback)
|
||||
updateCache(cached)
|
||||
// 看门狗:onPageFinished 一直不回调(WebView 静默失效)时给出**明确原因**。
|
||||
// 否则远程日志里只会看到反复的「15s 未就绪」,不知道卡在哪一步。
|
||||
mainHandler.postDelayed({
|
||||
if (!ready && !destroyed && !unavailable) {
|
||||
markUnavailable("初始化超时:onPageFinished 未回调或 KtvApi 未导出")
|
||||
callback(false)
|
||||
}
|
||||
}, BRIDGE_INIT_TIMEOUT_MS)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(TAG, "WebView init failed", error)
|
||||
MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
|
||||
markUnavailable("WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
|
||||
callback(false)
|
||||
}
|
||||
}
|
||||
@@ -138,6 +159,12 @@ class KtvJsBridge(context: Context) {
|
||||
view.evaluateJavascript("typeof window.KtvApi === 'function'") { result ->
|
||||
if (result == "true") {
|
||||
ready = true
|
||||
// 桥后来就绪(例如热更新重载成功)→ 清除"不可用"标记,恢复 JS 主路径。
|
||||
if (unavailable) {
|
||||
unavailable = false
|
||||
MiliLog.info("JS-BRIDGE 已恢复可用(${unavailableReason.ifEmpty { "?" }} 已解除)")
|
||||
unavailableReason = ""
|
||||
}
|
||||
Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})")
|
||||
MiliLog.info("JS-BRIDGE ready (${if (loadedFromCache) "cache" else "asset"})")
|
||||
callback(true)
|
||||
@@ -150,7 +177,7 @@ class KtvJsBridge(context: Context) {
|
||||
Log.e(TAG, "KtvApi was not exported by JS")
|
||||
// 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪,
|
||||
// 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。
|
||||
MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用")
|
||||
markUnavailable("KtvApi 未导出")
|
||||
callback(false)
|
||||
}
|
||||
}
|
||||
@@ -163,6 +190,20 @@ class KtvJsBridge(context: Context) {
|
||||
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
|
||||
fun isReady(): Boolean = ready && !destroyed
|
||||
|
||||
/** 是否已判定本机 WebView 不可用(用于跳过必然失败的等待)。 */
|
||||
fun isUnavailable(): Boolean = unavailable && !destroyed && !ready
|
||||
|
||||
/**
|
||||
* 标记桥在本机不可用。**只在状态首次变化时上报一次**:
|
||||
* 这是持久状态,若每次点歌都记一条会把远程日志刷满(实测每首歌一条)。
|
||||
*/
|
||||
fun markUnavailable(reason: String) {
|
||||
if (unavailable) return
|
||||
unavailable = true
|
||||
unavailableReason = reason
|
||||
MiliLog.error("JS-BRIDGE 判定本机 WebView 不可用($reason),后续点歌直接走原生取址通道")
|
||||
}
|
||||
|
||||
/**
|
||||
* 供**原生兜底取址解析器**([SongUrlResolver])复用的「干净请求」通道。
|
||||
*
|
||||
@@ -294,7 +335,7 @@ class KtvJsBridge(context: Context) {
|
||||
runCatching {
|
||||
file.isFile && file.length() in 1_001..500_000 &&
|
||||
file.readText(Charsets.UTF_8).let {
|
||||
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 8")
|
||||
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 9")
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
@@ -320,7 +361,7 @@ class KtvJsBridge(context: Context) {
|
||||
val script = String(bytes, StandardCharsets.UTF_8)
|
||||
require(
|
||||
script.length > 1000 && script.contains("KtvApi") &&
|
||||
script.contains("KTV_BRIDGE_API: 8"),
|
||||
script.contains("KTV_BRIDGE_API: 9"),
|
||||
) { "Invalid or incompatible JS file" }
|
||||
tempFile.writeText(script, Charsets.UTF_8)
|
||||
if (!tempFile.renameTo(cacheFile)) {
|
||||
@@ -347,11 +388,28 @@ class KtvJsBridge(context: Context) {
|
||||
|
||||
private fun wrapHtml(script: String): String {
|
||||
val escaped = script.replace("</script", "<\\/script", ignoreCase = true)
|
||||
// console.log 桥接:ktv_api.js 的 _log()/_log 细节(Try source / Token FAIL / do.php code=
|
||||
// 等)原本只进 WebView 控制台,远程日志里完全看不到,点歌失败无从定位。
|
||||
// 这里把它转发到 android.log → MiliLog,并保留原始 console 行为。
|
||||
// 诊断 + console 桥(必须在主脚本之前执行):
|
||||
// 1) 上报 WebView 内核版本与 ES2017(async/await) 支持情况 —— 老盒子(Android 5.x 的
|
||||
// WebView 可能是 Chrome 39~4x)不支持 async/await 会让**整个脚本语法报错**,
|
||||
// 表现为 `window.KtvApi` 未导出、桥永远不就绪;这条日志可直接定死该原因。
|
||||
// (用 new Function 做特性探测,避免探测语句本身引起语法错误。)
|
||||
// 2) window.onerror 捕获脚本解析/运行错误 —— 语法错误也会触发它,能拿到真实报错。
|
||||
// 3) console.log 桥接:ktv_api.js 的 _log() 细节原本只进 WebView 控制台。
|
||||
val consoleBridge = """
|
||||
(function(){
|
||||
try {
|
||||
window.onerror = function(msg, src, line, col){
|
||||
try { android.log('JS-ERROR ' + msg + ' @' + line + ':' + col); } catch(e) {}
|
||||
};
|
||||
} catch(e) {}
|
||||
try {
|
||||
var es = 'unknown';
|
||||
try { new Function('return (async function(){})'); es = 'yes'; } catch(e) { es = 'no'; }
|
||||
try {
|
||||
android.log('WEBVIEW ua=' + navigator.userAgent +
|
||||
' asyncFunction=' + es + ' promise=' + (typeof Promise));
|
||||
} catch(e) {}
|
||||
} catch(e) {}
|
||||
try {
|
||||
var orig = console.log;
|
||||
console.log = function(){
|
||||
@@ -493,8 +551,16 @@ class KtvJsBridge(context: Context) {
|
||||
// 用 TlsCompat 的工厂(系统信任 ∪ 内置根证书):裸 socket 走的是
|
||||
// SSLSocketFactory.getDefault(),不受 HttpsURLConnection 的默认工厂影响,
|
||||
// network_security_config 也管不到它 —— 老设备上会因缺 ISRG 新根握手失败。
|
||||
(TlsCompat.sslSocketFactory().createSocket() as SSLSocket).apply {
|
||||
//
|
||||
// ⚠️ 必须用「先连接、再包装」的写法:`createSocket()`(无主机参数)拿到的
|
||||
// SSLSocket **不会带上主机名**,SNI 与证书主机名校验都会受影响(共享 CDN /
|
||||
// 反代多证书时直接握手失败)。`createSocket(plain, host, port, true)` 既补上
|
||||
// 主机名,又保住了上面显式设置的连接超时。
|
||||
val plain = Socket().apply {
|
||||
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
|
||||
}
|
||||
(TlsCompat.sslSocketFactory()
|
||||
.createSocket(plain, uri.host, port, true) as SSLSocket).apply {
|
||||
startHandshake()
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -36,8 +36,15 @@ object KtvNetworkMonitor {
|
||||
private var manager: ConnectivityManager? = null
|
||||
private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
/** 开始监测;[onChange] 只在状态真正变化时回调(主线程)。 */
|
||||
fun start(context: Context, onChange: (online: Boolean) -> Unit) {
|
||||
/**
|
||||
* 开始监测;[onChange] 在**状态变化**时回调(主线程)。
|
||||
*
|
||||
* ⚠️ 第二参数 [initial] 区分「启动时的初始状态」与「真正的断网→恢复」:
|
||||
* 注册回调的当下系统就会回报一次初始状态,上层若把它当成"网络已恢复",
|
||||
* 就会在启动阶段做一遍**重复且有害**的恢复动作(重建歌曲源会话、复位窗口守卫、
|
||||
* 把状态栏写成"网络已恢复")。见 [MainActivity.onNetworkStateChanged]。
|
||||
*/
|
||||
fun start(context: Context, onChange: (online: Boolean, initial: Boolean) -> Unit) {
|
||||
if (callback != null) return
|
||||
val cm = context.applicationContext
|
||||
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
@@ -76,7 +83,7 @@ object KtvNetworkMonitor {
|
||||
manager = null
|
||||
}
|
||||
Log.i(TAG, "monitor started, online=$initial registered=$registered")
|
||||
onChange(initial)
|
||||
onChange(initial, true)
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
@@ -88,17 +95,17 @@ object KtvNetworkMonitor {
|
||||
Log.i(TAG, "monitor stopped")
|
||||
}
|
||||
|
||||
private fun publish(value: Boolean, onChange: (Boolean) -> Unit) {
|
||||
private fun publish(value: Boolean, onChange: (Boolean, Boolean) -> Unit) {
|
||||
mainHandler.post {
|
||||
if (value == online) return@post
|
||||
online = value
|
||||
Log.i(TAG, "network state changed: online=$value")
|
||||
onChange(value)
|
||||
onChange(value, false)
|
||||
}
|
||||
}
|
||||
|
||||
/** 主动复检一次(例如从后台回到前台),有变化才回调。 */
|
||||
fun refresh(onChange: (online: Boolean) -> Unit) {
|
||||
fun refresh(onChange: (online: Boolean, initial: Boolean) -> Unit) {
|
||||
publish(currentOnline(manager), onChange)
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,29 @@ class MainActivity : AppCompatActivity() {
|
||||
private val keyboardSearchRunnable = Runnable { performSearch() }
|
||||
private val io: ExecutorService = Executors.newFixedThreadPool(4)
|
||||
private val stateIo: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
|
||||
/**
|
||||
* 「切歌 → 公放」专用单线程执行器。
|
||||
*
|
||||
* ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`streamDownloadAndPlay`)这类
|
||||
* 几十秒的长任务也在里面。极米 H1S 上并发点几首歌就能把池占满,切歌任务于是一直排队 ——
|
||||
* 旧歌播完后彻底没声、按什么都没反应(用户报的"老设备卡死",新设备因下载快而看不出来)。
|
||||
*/
|
||||
private val randomPlayExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "ktv-random-play").apply { isDaemon = true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 公放候选缓存(本机可播放歌曲):启动时后台预热一次,下载完成后增量更新。
|
||||
*
|
||||
* ⚠️ **切歌时不能现扫**:候选来自 `localSongInventory()` + `library.scanLocal()`,
|
||||
* 代价是「每个文件在头尾各读约 4.3MB 做 TS 校验」+「遍历 `/storage/ 各挂载点`
|
||||
* (老 ROM 上的失效挂载点会让 `File.listFiles()` 永久阻塞)」。新设备上很快,
|
||||
* 极米 H1S 上足以让切歌看起来卡死。
|
||||
*/
|
||||
private val randomCandidates = java.util.concurrent.ConcurrentHashMap<String, Song>()
|
||||
@Volatile private var randomCandidatesPrimed = false
|
||||
@Volatile private var randomCandidatesPriming = false
|
||||
private val library = SongLibrary()
|
||||
private lateinit var stateDatabase: KtvStateDatabase
|
||||
private lateinit var playbackEngine: KtvPlaybackEngine
|
||||
@@ -187,12 +210,32 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */
|
||||
private var heartbeatTicks = 0
|
||||
|
||||
private val ktvHeartbeatRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
sendDeviceHeartbeat(boot = false)
|
||||
// 每 6 小时留一条健康快照:日志此前只有"事件"(点歌/失败/升级),
|
||||
// 没有"状态样本",现场排查空间/曲库/网络随时间的变化时缺参照。
|
||||
heartbeatTicks++
|
||||
if (heartbeatTicks % 6 == 0) logHealthSnapshot()
|
||||
main.postDelayed(this, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** 健康快照:版本 / 在线 / 可用空间 / 曲库文件 —— 单行,便于按 HEALTH 检索。 */
|
||||
private fun logHealthSnapshot() {
|
||||
runCatching {
|
||||
val db = AppPaths.databaseFile
|
||||
val freeGb = AppPaths.databaseDir.usableSpace / 1024.0 / 1024.0 / 1024.0
|
||||
MiliLog.notice(
|
||||
"HEALTH ver=${BuildConfig.VERSION_NAME} online=${KtvNetworkMonitor.online}" +
|
||||
" free=${String.format(Locale.ROOT, "%.2f", freeGb)}GB" +
|
||||
" dbExist=${db.exists()} dbMB=${db.length() / 1024 / 1024}" +
|
||||
" root=${AppPaths.root.absolutePath}",
|
||||
)
|
||||
}
|
||||
}
|
||||
private val visibleSongs: MutableList<Song> = ArrayList<Song>()
|
||||
private val visibleSingers: MutableList<Array<String?>?> = ArrayList<Array<String?>?>()
|
||||
private val visiblePlaylists: MutableList<Array<String?>> = ArrayList()
|
||||
@@ -470,8 +513,10 @@ class MainActivity : AppCompatActivity() {
|
||||
playbackEngine = KtvPlaybackEngine(applicationContext)
|
||||
// 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
|
||||
MiliLog.init(applicationContext)
|
||||
// 存储根目录探测结果(排查不同盒子存储差异用;单行便于按 STORAGE 检索)。
|
||||
MiliLog.info("STORAGE ${AppPaths.storageFacts(this)}")
|
||||
// ⚠️ STORAGE 探测含真实建目录/写盘与 /proc/mounts 读取,**不能放在主线程**,
|
||||
// 而且必须在 refreshLogDeviceContext() 之后,否则这一行的
|
||||
// serverip/clientip/user/traceid 全是 undefined(旧日志就是这样,无法按设备检索)。
|
||||
// 现在统一在 reportDeviceBootInfo() 的后台线程里、awaitServerIp() 之后上报。
|
||||
refreshLogDeviceContext()
|
||||
reportDeviceBootInfo()
|
||||
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
|
||||
@@ -507,9 +552,18 @@ class MainActivity : AppCompatActivity() {
|
||||
initializeDatabase()
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
// 一次性清理"同一段视频被当成多首不同歌曲"的注入广告副本。
|
||||
// ⚠️ 只删可证明是广告的文件(多首歌内容完全相同);**绝不能清空整个歌曲目录** ——
|
||||
// 1.4.10 那样做过,等于删掉公放曲库,导致「切歌」选不出下一首(新设备回归)。
|
||||
io.execute { SongOkDownloadManager.purgeInjectedAdDuplicatesOnce(applicationContext) }
|
||||
// 预热公放候选缓存(独立守护线程 + 内部超时),让之后的「切歌 → 公放」零 I/O。
|
||||
// 延后一点避开曲库加载/启动高峰,但不影响正确性:预热完成前切歌会走安全兜底路径。
|
||||
main.postDelayed({ primeRandomCandidates() }, 5_000L)
|
||||
main.postDelayed(remoteNetworkMonitor, 5000)
|
||||
// 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。
|
||||
KtvNetworkMonitor.start(applicationContext) { online -> onNetworkStateChanged(online) }
|
||||
KtvNetworkMonitor.start(applicationContext) { online, initial ->
|
||||
onNetworkStateChanged(online, initial)
|
||||
}
|
||||
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
|
||||
sendDeviceHeartbeat(boot = true)
|
||||
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
||||
@@ -3662,13 +3716,24 @@ class MainActivity : AppCompatActivity() {
|
||||
* 4. 拉起本地点歌服务并刷新二维码;
|
||||
* 5. 自动重试断网期间失败的下载。
|
||||
*/
|
||||
private fun onNetworkStateChanged(online: Boolean) {
|
||||
private fun onNetworkStateChanged(online: Boolean, initial: Boolean = false) {
|
||||
fullScreenNetworkBadge?.visibility = if (online) View.GONE else View.VISIBLE
|
||||
if (!online) {
|
||||
status?.text = "网络未连接"
|
||||
MiliLog.warn("NET-DOWN 设备网络已断开")
|
||||
return
|
||||
}
|
||||
// ⚠️ initial=true 表示「启动时的初始状态」,**不是**「断网后恢复」。
|
||||
// 启动流程(onCreate)已经做过 startRemoteServer()/updateMobileQrOverlay()/
|
||||
// refreshLogDeviceContext()/DeviceMac 解析,这里再走一遍恢复逻辑只会:
|
||||
// 1) 白白清掉刚建立的歌曲源会话(resetSourceSession);
|
||||
// 2) 语义错误地 noteUrlOk(),可能掩盖真正的歌曲源窗口到期提示;
|
||||
// 3) 把状态栏错写成"网络已恢复"(启动瞬间就显示,用户可见的错误文案)。
|
||||
// 因此初始状态只更新断网角标,不做任何恢复动作。
|
||||
if (initial) {
|
||||
Log.i(TAG, "initial network state: online=true (skip recovery actions)")
|
||||
return
|
||||
}
|
||||
MiliLog.notice("NET-UP 设备网络已恢复, 重建歌曲源会话")
|
||||
if (!remoteServer.isRunning()) startRemoteServer()
|
||||
refreshNetworkState("net-up", notify = true)
|
||||
@@ -3725,6 +3790,9 @@ class MainActivity : AppCompatActivity() {
|
||||
// 等主机 IP(公网出口 IP)就绪,避免启动这条日志的 serverip 为空。
|
||||
MiliLog.awaitServerIp()
|
||||
MiliLog.notice(sb.toString())
|
||||
// 存储根目录探测结果(排查不同盒子存储差异用;单行便于按 STORAGE 检索)。
|
||||
// 放在这里而不是启动主线程:既有完整上下文,又避免了主线程磁盘探测。
|
||||
runCatching { MiliLog.notice("STORAGE ${AppPaths.storageFacts(this@MainActivity)}") }
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
}, "ktv-boot-log").start()
|
||||
@@ -4984,6 +5052,20 @@ class MainActivity : AppCompatActivity() {
|
||||
showDownloads()
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断歌曲是否已有可播放的本地文件(**廉价**检查:只看存在与大小)。
|
||||
*
|
||||
* ⚠️ 播放路径上不要用 `Song.hasLocalFile()`:它内部是 `SongFileValidator.inspect`,
|
||||
* 会在文件**头尾各读约 4.3MB** 做 TS 包校验 —— 极米 H1S 这类慢存储上一次调用就是好几秒,
|
||||
* 足以让主线程 ANR(用户感知即"按了切歌卡死")。深度校验统一交给后台
|
||||
* (`localSongInventory()` / `SongOkDownloadManager.isDownloaded()`)完成。
|
||||
*/
|
||||
private fun hasPlayableLocalFile(song: Song?): Boolean {
|
||||
val path = song?.path?.takeIf { it.isNotEmpty() } ?: return false
|
||||
val file = File(path)
|
||||
return file.isFile && file.length() >= SongFileValidator.MIN_VALID_FILE_SIZE
|
||||
}
|
||||
|
||||
private fun play(song: Song?) {
|
||||
if (player == null) player = videoBackground
|
||||
if (song == null) return
|
||||
@@ -4991,7 +5073,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// 远程日志: 点歌使用的源(本地文件 / 在线源)。
|
||||
MiliLog.info(
|
||||
"PLAY id=${extractTid(song)} title=${song.title} src=" +
|
||||
(if (song.hasLocalFile()) "local" else "online"),
|
||||
(if (hasPlayableLocalFile(song)) "local" else "online"),
|
||||
)
|
||||
// 取消仍在后台扫描的“已点播完后随机本地歌曲”请求,避免抢占新点歌曲。
|
||||
randomLocalRequestSerial++
|
||||
@@ -5012,7 +5094,7 @@ class MainActivity : AppCompatActivity() {
|
||||
updateBottomBar(song, true)
|
||||
scheduleAutoFullscreen()
|
||||
|
||||
if (song.hasLocalFile()) {
|
||||
if (hasPlayableLocalFile(song)) {
|
||||
playLocalFile(song)
|
||||
} else {
|
||||
playbackPreparing = true
|
||||
@@ -5140,6 +5222,7 @@ class MainActivity : AppCompatActivity() {
|
||||
// 移到正式位置
|
||||
tmpFile.renameTo(finalFile)
|
||||
song.path = finalFile.getAbsolutePath()
|
||||
randomCandidates[stableId(song)] = song
|
||||
|
||||
main.post(Runnable {
|
||||
hideDownloadProgress()
|
||||
@@ -5241,9 +5324,9 @@ class MainActivity : AppCompatActivity() {
|
||||
// 远程日志: 播放网络/解码错误实时上报。
|
||||
MiliLog.error(
|
||||
"PLAY-ERR id=${song?.let(::extractTid)} title=${song?.title} what=$what extra=$extra" +
|
||||
(if (song?.hasLocalFile() == true) " local" else " stream"),
|
||||
(if (hasPlayableLocalFile(song)) " local" else " stream"),
|
||||
)
|
||||
if (songRetryCount < 2 && song != null && song.hasLocalFile()) {
|
||||
if (songRetryCount < 2 && song != null && hasPlayableLocalFile(song)) {
|
||||
songRetryCount++
|
||||
val retry = songRetryCount
|
||||
main.postDelayed(Runnable {
|
||||
@@ -5260,7 +5343,7 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun switchToLocalPlayback(song: Song?) {
|
||||
if (currentSong == null || !currentSong!!.equals(song)) return
|
||||
if (!song!!.hasLocalFile()) return
|
||||
if (song == null || !hasPlayableLocalFile(song)) return
|
||||
var pos = 0
|
||||
try {
|
||||
pos = player!!.currentPosition
|
||||
@@ -5403,6 +5486,8 @@ class MainActivity : AppCompatActivity() {
|
||||
main.post(Runnable {
|
||||
downloads.removeAll { stableId(it.song) == stableId(song) }
|
||||
song.path = localPath
|
||||
// 立刻进公放候选缓存:不必等下次预热扫描,新下载的歌马上能被随机到。
|
||||
randomCandidates[stableId(song)] = song
|
||||
refreshSongAdapter()
|
||||
val shouldAddToQueue = pendingQueueSongIds.remove(stableId(song))
|
||||
Log.i(
|
||||
@@ -5524,6 +5609,11 @@ class MainActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
// 其它情况(没有公放曲目在播 / 用户主动停止):取消挂起的随机续播,进入空闲态。
|
||||
// 一并停掉播放器并清空播放器状态:只要出现"音频在跑但 currentSong 已空",
|
||||
// 那首歌播完时 onCompletion 就会因 currentSong 不匹配而直接 return,
|
||||
// 之后按切歌/播放都没有反应(僵尸态)。
|
||||
if (player != null) runCatching { player!!.stopPlayback() }
|
||||
currentMediaPlayer = null
|
||||
randomLocalRequestSerial++
|
||||
playbackPreparing = false
|
||||
playWhenPrepared = false
|
||||
@@ -5548,11 +5638,23 @@ class MainActivity : AppCompatActivity() {
|
||||
} else if (fromCompletion && autoNext && orderQueue.isEmpty()) {
|
||||
// 点歌清单播放完毕 → 自动续播本地已下载歌曲
|
||||
playRandomLocalSong()
|
||||
} else if (!fromCompletion) {
|
||||
// 点歌清单**最后一首**还在播时按「切歌」→ 直接进入公放(随机本地歌曲)。
|
||||
// ⚠️ 旧逻辑在这里只把 currentSong 置空、**没有停播放器**:那首歌会继续播完,
|
||||
// 而 onCompletion 的第一行是 `currentSong?.equals(song) != true → return`,
|
||||
// 于是再也不会推进 —— 形成"音频已结束 / currentSong 为空 / playWhenPrepared=false"
|
||||
// 的僵尸态,之后按「切歌」「播放」都毫无反应(用户报的卡死)。
|
||||
// 公放清单与点歌清单互不影响:随机歌曲不入 orderQueue,点歌时又会立即取消随机请求。
|
||||
MiliLog.info("点歌清单已空,切歌转入公放(随机本地歌曲)")
|
||||
playRandomLocalSong(skipped = true)
|
||||
} else {
|
||||
// 显式切歌但队列已空:同样取消挂起的随机续播,避免用户停歌后又被随机歌打断。
|
||||
// 手动切歌模式(autoNext=false)下自然播完:保持原逻辑,停在空闲态等待点歌。
|
||||
// 这里把播放器一并停掉并清空状态,避免出现上面那种"音频在跑、状态已清"的僵尸态。
|
||||
if (player != null) runCatching { player!!.stopPlayback() }
|
||||
randomLocalRequestSerial++
|
||||
playbackPreparing = false
|
||||
playWhenPrepared = false
|
||||
currentMediaPlayer = null
|
||||
releaseVocalPlayer()
|
||||
currentSong = null
|
||||
clearLyrics()
|
||||
@@ -5580,6 +5682,9 @@ class MainActivity : AppCompatActivity() {
|
||||
val requestSerial = ++randomLocalRequestSerial
|
||||
playbackPreparing = true
|
||||
playWhenPrepared = true
|
||||
// 这里**不立刻停播放器**:本地曲库扫描(含文件校验)要花一点时间,
|
||||
// 让当前这首歌继续放到下一首真正开始时再停,避免"切歌后一段静音"。
|
||||
// 僵尸态的防范放在"确实没歌可播"的分支里(见下面 selected == null 的处理)。
|
||||
releaseVocalPlayer()
|
||||
currentSong = null
|
||||
clearLyrics()
|
||||
@@ -5591,17 +5696,21 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
persistRuntimeState()
|
||||
|
||||
io.execute {
|
||||
val localSongs = localSongInventory()
|
||||
library.scanLocal().forEach { song ->
|
||||
val file = song.path?.let(::File) ?: return@forEach
|
||||
if (SongFileValidator.inspect(
|
||||
file,
|
||||
SongFileValidator.requiresTransportStream(file),
|
||||
).valid
|
||||
) {
|
||||
localSongs.getOrPut(stableId(song)) { song }
|
||||
}
|
||||
// ⚠️ 两条硬约束:
|
||||
// 1) 必须用 randomPlayExecutor(专用线程),否则会被几十秒的下载任务挤在后面排队;
|
||||
// 2) 必须优先读预热缓存,不能在切歌时现扫目录(老盒子上"逐文件校验 + 遍历 /storage/ 各挂载点"
|
||||
// 会让切歌看起来卡死)。
|
||||
randomPlayExecutor.execute {
|
||||
val localSongs = LinkedHashMap<String, Song>()
|
||||
randomCandidates.forEach { (id, song) -> localSongs[id] = song }
|
||||
if (localSongs.isEmpty()) {
|
||||
// 缓存还没预热好(例如刚开机就切歌):做一次**安全的**库存查询兜底。
|
||||
// 刻意不调用 library.scanLocal() —— 它会遍历 /storage/ 各挂载点,老 ROM 的失效挂载点
|
||||
// 可能让 File.listFiles() 永久阻塞;外部目录交给带超时的预热流程去补。
|
||||
runCatching {
|
||||
localSongInventory().forEach { (id, song) -> localSongs[id] = song }
|
||||
}.onFailure { Log.w(TAG, "random candidates fallback failed", it) }
|
||||
primeRandomCandidates()
|
||||
}
|
||||
val allLocalSongs = localSongs.values.toList()
|
||||
main.post {
|
||||
@@ -5620,6 +5729,12 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
play(selected)
|
||||
} else {
|
||||
// ⚠️ 本地确实没有可播歌曲:必须**停掉播放器**再进入空闲态。
|
||||
// 否则会出现"歌曲还在放、currentSong 却已置空"的僵尸态 —— 那首歌播完时
|
||||
// onCompletion 因 `currentSong?.equals(song) != true` 直接 return,
|
||||
// 之后按切歌/播放都没反应(本轮用户报的卡死正是这一类状态)。
|
||||
if (player != null) runCatching { player!!.stopPlayback() }
|
||||
currentMediaPlayer = null
|
||||
playbackPreparing = false
|
||||
playWhenPrepared = false
|
||||
updateBottomBar(null, false)
|
||||
@@ -8710,6 +8825,66 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun countDownloadedFiles(): Int = localSongInventory().size
|
||||
|
||||
/**
|
||||
* 预热公放候选缓存(启动时调用一次;下载完成后由回调增量补充)。
|
||||
*
|
||||
* 用**独立守护线程**而不是 [io] 池:里面的 `library.scanLocal()` 在老盒子上可能永久阻塞,
|
||||
* 绝不能占住 io 线程;[scanWithTimeout] 保证即使它卡住,也只损失"导入目录/外置存储"那部分候选,
|
||||
* 已下载曲库照样可用。
|
||||
*/
|
||||
private fun primeRandomCandidates() {
|
||||
if (randomCandidatesPriming || randomCandidatesPrimed) return
|
||||
randomCandidatesPriming = true
|
||||
Thread({
|
||||
val startedAt = System.currentTimeMillis()
|
||||
runCatching {
|
||||
// 1) 已下载曲库:只读我们自己的目录 + 曲库 DB,安全。
|
||||
localSongInventory().forEach { (id, song) -> randomCandidates.putIfAbsent(id, song) }
|
||||
// 2) 导入目录 / 外置存储:遍历 /storage/ 各挂载点 有阻塞风险,必须带超时。
|
||||
val scanned = scanWithTimeout(RANDOM_SCAN_TIMEOUT_MS) { library.scanLocal() }
|
||||
if (scanned == null) {
|
||||
MiliLog.warn("RANDOM-CACHE 目录扫描超时(${RANDOM_SCAN_TIMEOUT_MS}ms),仅使用已下载曲库")
|
||||
} else {
|
||||
scanned.forEach { song ->
|
||||
val file = song.path?.let(::File) ?: return@forEach
|
||||
if (SongFileValidator.inspect(
|
||||
file,
|
||||
SongFileValidator.requiresTransportStream(file),
|
||||
).valid
|
||||
) {
|
||||
randomCandidates.putIfAbsent(stableId(song), song)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { Log.w(TAG, "prime random candidates failed", it) }
|
||||
randomCandidatesPrimed = true
|
||||
randomCandidatesPriming = false
|
||||
MiliLog.info(
|
||||
"RANDOM-CACHE 预热完成 size=${randomCandidates.size} " +
|
||||
"用时=${System.currentTimeMillis() - startedAt}ms",
|
||||
)
|
||||
}, "ktv-random-prime").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* 在独立守护线程里执行"可能永久阻塞"的文件系统扫描,超时返回 null。
|
||||
*
|
||||
* 不能放进任何线程池:老盒子(极米 H1S,非标准 ROM)上 `/storage/ 各挂载点` 里的失效挂载点
|
||||
* 会让 `File.listFiles()` 无限期阻塞,占住池线程会连带拖死后续所有任务。
|
||||
*/
|
||||
private fun <T> scanWithTimeout(timeoutMs: Long, block: () -> T): T? {
|
||||
val holder = java.util.concurrent.atomic.AtomicReference<T>()
|
||||
val done = java.util.concurrent.CountDownLatch(1)
|
||||
Thread({
|
||||
runCatching { holder.set(block()) }
|
||||
done.countDown()
|
||||
}, "ktv-scan").apply { isDaemon = true }.start()
|
||||
val finished = runCatching {
|
||||
done.await(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
}.getOrDefault(false)
|
||||
return if (finished) holder.get() else null
|
||||
}
|
||||
|
||||
private fun localSongInventory(): LinkedHashMap<String, Song> {
|
||||
val result = LinkedHashMap<String, Song>()
|
||||
library.muse.localPathSongs().forEach { song ->
|
||||
@@ -9698,7 +9873,7 @@ class MainActivity : AppCompatActivity() {
|
||||
return SongRowState(
|
||||
current = currentSong?.equals(song) == true,
|
||||
queued = orderQueue!!.contains(song),
|
||||
local = song.hasLocalFile(),
|
||||
local = hasPlayableLocalFile(song),
|
||||
downloading = isDownloading(song),
|
||||
failed = taskState.startsWith("失败"),
|
||||
paused = taskState.contains("暂停") || taskState.contains("中断"),
|
||||
@@ -9932,7 +10107,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun isSongReadyForQueue(song: Song): Boolean =
|
||||
SongOkDownloadManager.isDownloaded(song) || song.hasLocalFile()
|
||||
hasPlayableLocalFile(song) || SongOkDownloadManager.isDownloaded(song)
|
||||
|
||||
private fun addReadySongToQueue(song: Song) {
|
||||
if (orderQueue!!.contains(song)) return
|
||||
@@ -10530,6 +10705,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val LEGACY_PACKAGE = "com.origjoy.local.ktv"
|
||||
private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id
|
||||
private const val HEARTBEAT_INTERVAL_MS = 3600_000L
|
||||
/** 公放候选预热时,外部目录扫描(可能遍历 /storage/ 各挂载点)的超时上限。 */
|
||||
private const val RANDOM_SCAN_TIMEOUT_MS = 4_000L
|
||||
|
||||
// ===== 主题色(对标原版麦动 配色) =====
|
||||
/** 主背景:极深蓝黑(原版 #080C12 系) */
|
||||
|
||||
@@ -29,8 +29,8 @@ object SongApiClient {
|
||||
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
|
||||
bridge?.init { ok ->
|
||||
ready = ok
|
||||
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
|
||||
Log.i(TAG, "JS Bridge init: $ok")
|
||||
if (!ok) MiliLog.error("JS-BRIDGE 初始化返回 false,取址将走原生兜底")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,19 @@ object SongApiClient {
|
||||
@JvmStatic
|
||||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
|
||||
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
||||
// ⚠️ 主线程**绝不能**走网络路径:原生兜底最多 7 个源 × 8 秒超时,会把 UI 线程
|
||||
// 卡死到 ANR(1.4.7 曾把这里改成先 resolveNatively 再回退,属于隐患)。
|
||||
// 主线程只做本地的 DB 兜底,网络取址一律留给后台线程。
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
Log.w(TAG, "Synchronous JS request on main thread; using native/DB fallback")
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
|
||||
return fetchFromDb(musicno)
|
||||
}
|
||||
val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
// 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。
|
||||
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
|
||||
if (bridge.isUnavailable()) {
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
}
|
||||
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
||||
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
||||
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
|
||||
@@ -67,9 +75,10 @@ object SongApiClient {
|
||||
}
|
||||
if (!bridge.isReady()) {
|
||||
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
|
||||
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器。
|
||||
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器,
|
||||
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
|
||||
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
||||
MiliLog.warn("JS-BRIDGE 15s 未就绪,改用原生取址通道")
|
||||
bridge.markUnavailable("15s 未就绪")
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
}
|
||||
val result = AtomicReference<String?>()
|
||||
@@ -106,8 +115,11 @@ object SongApiClient {
|
||||
if (db.open()) {
|
||||
val url = db.getCloudUrl(musicno)
|
||||
db.close()
|
||||
// DB 中的 cloud_url 可能也是 demo 地址, 做 URL 基本校验
|
||||
if (url != null && !isLikelyDemoUrl(url)) return url
|
||||
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
|
||||
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
|
||||
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
return null
|
||||
@@ -140,7 +152,8 @@ object SongApiClient {
|
||||
bridge?.resetApiSession()
|
||||
}
|
||||
|
||||
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
|
||||
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
|
||||
@JvmStatic
|
||||
fun lastSongSourceHost(): String = bridge?.lastSongSourceHost.orEmpty()
|
||||
fun lastSongSourceHost(): String =
|
||||
bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ object SongOkDownloadManager {
|
||||
private const val MAX_RETRY_COUNT = 3
|
||||
private const val STALL_TIMEOUT_MS = 25_000L
|
||||
|
||||
/** 曲库视频目录下会被当作"已下载歌曲"的文件后缀(用于一次性清理历史广告缓存)。 */
|
||||
private val MEDIA_EXTENSIONS = setOf("ts", "ls", "mp4", "mkv")
|
||||
|
||||
interface DownloadCallback {
|
||||
fun onDownloadStart(song: Song)
|
||||
fun onDownloadProgress(song: Song, progress: Int)
|
||||
@@ -73,6 +76,76 @@ object SongOkDownloadManager {
|
||||
return downloaded
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性清理"同一段视频被当成多首不同歌曲"的注入广告。
|
||||
*
|
||||
* 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里
|
||||
* **完全没有过广告校验**,广告视频(如 `gz.ac16.vip/ad_files/my_ad_video.ts`)会被当成歌曲
|
||||
* 下载并落盘;它又能通过 [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),
|
||||
* 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。
|
||||
*
|
||||
* ⚠️ **不能像 1.4.10 那样直接清空整个歌曲目录**:`MuseDatabase.songDirectories()` 只有
|
||||
* `cloudSongsDir` 一个目录,那等于把**公放曲库整个删掉** —— 库里没歌,
|
||||
* `playRandomLocalSong()` 选不出候选、`currentSong` 保持 null,
|
||||
* 于是「切歌」落进空闲态分支、按了没反应(新设备上的回归)。
|
||||
*
|
||||
* 这里改为**只删真正能证明是注入广告的文件**:广告对每首歌都下发**同一个视频文件**,
|
||||
* 所以它的特征是「多首不同歌曲的本地文件**内容完全相同**」。按「文件大小 + 头部指纹」
|
||||
* 分组,只清理 ≥2 个文件完全相同的组 —— 正常歌曲各有各的内容,绝不会被误删。
|
||||
*
|
||||
* @return 删除的文件数(已清理过则返回 0)
|
||||
*/
|
||||
@JvmStatic
|
||||
fun purgeInjectedAdDuplicatesOnce(context: android.content.Context): Int {
|
||||
val marker = File(context.filesDir, "ad_dup_purge_v1")
|
||||
if (marker.exists()) return 0
|
||||
var removed = 0
|
||||
var groups = 0
|
||||
runCatching {
|
||||
val files = AppPaths.cloudSongsDir.listFiles().orEmpty()
|
||||
.filter { it.isFile && it.extension.lowercase() in MEDIA_EXTENSIONS && it.length() > 0 }
|
||||
// 第一层:按大小分组(同尺寸才会有嫌疑);第二层:比对头部指纹做双重确认。
|
||||
files.groupBy { it.length() }
|
||||
.filterValues { it.size >= 2 }
|
||||
.forEach { (_, sameSize) ->
|
||||
sameSize.groupBy { headFingerprint(it) }
|
||||
.forEach { (fingerprint, duplicates) ->
|
||||
if (fingerprint != null && duplicates.size >= 2) {
|
||||
groups++
|
||||
duplicates.forEach { file ->
|
||||
SongFileValidator.forget(file)
|
||||
if (file.delete()) removed++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
marker.writeText("purged=${System.currentTimeMillis()} removed=$removed groups=$groups", Charsets.UTF_8)
|
||||
}.onFailure { Log.w(TAG, "purge injected ad duplicates failed: ${it.message}") }
|
||||
if (removed > 0) {
|
||||
MiliLog.warn(
|
||||
"SONG-AD 清理注入广告副本 removed=$removed groups=$groups(多首歌共用同一段视频)" +
|
||||
" 保留正常歌曲=${AppPaths.cloudSongsDir.listFiles()?.size ?: 0}",
|
||||
)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/** 文件头部 4MB 的 MD5,用于"同一段视频"的判定(读全文件太慢,广告同一文件必然头部也相同)。 */
|
||||
private fun headFingerprint(file: File): String? = runCatching {
|
||||
val digest = java.security.MessageDigest.getInstance("MD5")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(1024 * 1024)
|
||||
var total = 0
|
||||
while (total < 4 * 1024 * 1024) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
digest.update(buffer, 0, read)
|
||||
total += read
|
||||
}
|
||||
}
|
||||
digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}.getOrNull()
|
||||
|
||||
@JvmStatic
|
||||
fun isDownloading(song: Song): Boolean {
|
||||
val key = songKey(song)
|
||||
@@ -173,33 +246,71 @@ object SongOkDownloadManager {
|
||||
}
|
||||
|
||||
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
|
||||
// ⚠️ 只有「从文件名推出」的点歌编号才可信,才能参与 `.ts` 文件名比对。
|
||||
// `song.id` 在曲库条目里可能是数据库主键(非点歌编号),拿它去比对会把**正常地址误杀**
|
||||
// (表现:真源地址被判"文件名不符"→ 永远下不到歌)。此时传 null = 跳过文件名规则,
|
||||
// 仅保留广告域名/路径特征那两条判定。
|
||||
val musicNoFromFile = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() }
|
||||
val musicNo = musicNoFromFile ?: song.id
|
||||
if (!forceRefresh) {
|
||||
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
|
||||
if (!AdVideoFilter.isAdVideo(cached)) return cached
|
||||
// 缓存的下载地址是广告视频,清掉并重新解析(广告会被桥接层屏蔽/替换)。
|
||||
Log.w(TAG, "丢弃广告缓存地址: ${song.title}")
|
||||
if (AdVideoFilter.isTrustedSongUrl(cached, musicNoFromFile)) return cached
|
||||
// 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的
|
||||
// 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整),
|
||||
// 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。
|
||||
Log.w(TAG, "丢弃不可信缓存地址: ${song.title} -> $cached")
|
||||
MiliLog.warn("SONG-AD 丢弃不可信缓存地址 编号=$musicNo url=$cached")
|
||||
song.downloadUrl = null
|
||||
purgeCachedSongFile(song, "untrusted-cached-url")
|
||||
}
|
||||
}
|
||||
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
|
||||
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
|
||||
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
|
||||
val srcHost = SongApiClient.lastSongSourceHost()
|
||||
if (url.isNotEmpty()) {
|
||||
// ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退,
|
||||
// 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回
|
||||
// gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。
|
||||
if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNoFromFile)) {
|
||||
song.downloadUrl = url
|
||||
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
|
||||
MiliLog.info(
|
||||
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost",
|
||||
)
|
||||
} else if (url.isNotEmpty()) {
|
||||
Log.w(TAG, "Untrusted URL rejected for ${song.title}: $url")
|
||||
MiliLog.error(
|
||||
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 " +
|
||||
"详情=地址不可信(广告或文件名不符) url=$url",
|
||||
)
|
||||
purgeCachedSongFile(song, "untrusted-resolved-url")
|
||||
""
|
||||
} else {
|
||||
Log.w(TAG, "Unable to get download URL: ${song.title}")
|
||||
MiliLog.error(
|
||||
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址",
|
||||
)
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除某首歌已落盘的文件(发现地址不可信/命中广告时调用,让下次点歌重新取址下载)。 */
|
||||
private fun purgeCachedSongFile(song: Song, reason: String) {
|
||||
runCatching {
|
||||
val file = getLocalFile(song)
|
||||
if (file.exists()) {
|
||||
val sizeMb = file.length() / 1024 / 1024
|
||||
SongFileValidator.forget(file)
|
||||
val deleted = file.delete()
|
||||
MiliLog.warn(
|
||||
"SONG-AD 清除可疑歌曲缓存 编号=${song.id} 原因=$reason " +
|
||||
"大小=${sizeMb}MB 已删除=$deleted",
|
||||
)
|
||||
}
|
||||
song.path = null
|
||||
}.onFailure { Log.w(TAG, "purge cached file failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun startDownloadTask(song: Song, url: String, callback: DownloadCallback?, attempt: Int) {
|
||||
val key = songKey(song)
|
||||
val target = getLocalFile(song)
|
||||
|
||||
@@ -56,6 +56,15 @@ object SongUrlResolver {
|
||||
|
||||
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN
|
||||
|
||||
/**
|
||||
* 最近一次取址命中的源 host。
|
||||
* 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的
|
||||
* `reportSongResult` 不会触发,否则日志里「请求源=」永远是空的。
|
||||
*/
|
||||
@Volatile
|
||||
var lastHost: String = ""
|
||||
private set
|
||||
|
||||
/**
|
||||
* 安装取址通道。
|
||||
* @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]);
|
||||
@@ -115,6 +124,7 @@ object SongUrlResolver {
|
||||
}
|
||||
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
|
||||
promote(host)
|
||||
lastHost = host
|
||||
val elapsed = System.currentTimeMillis() - startedAt
|
||||
val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?"
|
||||
MiliLog.info(
|
||||
@@ -192,15 +202,16 @@ object SongUrlResolver {
|
||||
}
|
||||
}
|
||||
|
||||
/** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */
|
||||
/**
|
||||
* 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名
|
||||
* 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。
|
||||
*/
|
||||
private fun isValidSongUrl(url: String, musicno: String): Boolean {
|
||||
if (AdVideoFilter.isAdVideo(url)) return false
|
||||
if (!AdVideoFilter.isTrustedSongUrl(url, musicno)) return false
|
||||
val lower = url.lowercase()
|
||||
if (lower.contains("wb66") || lower.contains("demo")) return false
|
||||
if (IP_URL.containsMatchIn(url)) return false
|
||||
if (R2_DEMO.containsMatchIn(url)) return false
|
||||
val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1)
|
||||
if (matched != null && matched != musicno) return false
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.local.ktv;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
/**
|
||||
* 广告地址拦截的回归测试。
|
||||
*
|
||||
* 对应 2026-09-26 现场事故:新设备(WebView 正常 → 走 JS 取址)点歌 7303235(情歌 HD)
|
||||
* 拿到 `gz.ac16.vip` 的广告视频,被当成歌曲下载并播放。当时的漏洞有三处:
|
||||
* 1. JS 的 isDemoUrl 不认识该广告域名,且只比对"纯数字文件名",非数字名字直接放行;
|
||||
* 2. Kotlin 的 buildDownloadUrl 成功分支**完全没做广告校验**;
|
||||
* 3. DB 回退路径只做 isLikelyDemoUrl,也挡不住广告域名。
|
||||
* 这里用日志里的**真实 URL 形态**锁住入参校验行为。
|
||||
*/
|
||||
public final class AdVideoFilterTest {
|
||||
|
||||
/** 真源地址:两个 CDN 域实测文件名都严格等于点歌编号,必须放行。 */
|
||||
@Test
|
||||
public void acceptsRealSourceUrls() {
|
||||
assertTrue(AdVideoFilter.isTrustedSongUrl(
|
||||
"http://txog.ktvsky.com/264/lvbr/480/E/ts/47.1/202209220333/480p/7303235.ts?token=526e505e",
|
||||
"7303235"));
|
||||
assertTrue(AdVideoFilter.isTrustedSongUrl(
|
||||
"http://download.origjoy.com/264/lvbr/480/E/ts/39.6/202309070206/480p/7445418.ts?token=abc",
|
||||
"7445418"));
|
||||
}
|
||||
|
||||
/** 广告域名:即使文件名伪装成正确的点歌编号,也必须拦下。 */
|
||||
@Test
|
||||
public void rejectsAdHostEvenWithMatchingName() {
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("http://gz.ac16.vip/ad/7303235.ts", "7303235"));
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("http://cdn.ac16.vip/x/7303235.ts", "7303235"));
|
||||
}
|
||||
|
||||
/** 广告文件名的经典形态:非数字名字,旧规则的正则匹配不到。 */
|
||||
@Test
|
||||
public void rejectsAdFileName() {
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/ad_files/my_ad_video.ts", "7303235"));
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/my_ad_video.ts", "7303235"));
|
||||
}
|
||||
|
||||
/** .ts 文件名与点歌编号不符(即使不命中任何广告域名)也视为不可信。 */
|
||||
@Test
|
||||
public void rejectsMismatchedFileName() {
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/9999999.ts", "7303235"));
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("http://example.com/x/promo.ts", "7303235"));
|
||||
}
|
||||
|
||||
/** 非 http(s) 一律不可信。 */
|
||||
@Test
|
||||
public void rejectsNonHttpSchemes() {
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("file:///sdcard/x/7303235.ts", "7303235"));
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("ftp://example.com/7303235.ts", "7303235"));
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl("", "7303235"));
|
||||
assertFalse(AdVideoFilter.isTrustedSongUrl(null, "7303235"));
|
||||
}
|
||||
|
||||
/** 没有 .ts 文件名时不做文件名比对(避免误杀非 ts 的真源)。 */
|
||||
@Test
|
||||
public void allowsNonTsUrlWithoutAdMarkers() {
|
||||
assertTrue(AdVideoFilter.isTrustedSongUrl("http://cdn.example.com/path/stream?token=abc", "7303235"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user