V1.5.0: 修复"公放切歌失效"——上一版清空歌曲缓存把公放曲库删了(新设备回归)
现象: 新设备公放时按「切歌」没有任何反应。 根因(我上一版引入的回归): - 1.4.10 为了让"被当成歌曲缓存下来的广告视频"消失,在启动时做了一次 **整目录清理**(删 cloud-song 下所有 .ts/.ls/.mp4/.mkv)。 - 但 MuseDatabase.songDirectories() **只有 cloudSongsDir 一个目录**, SongOkDownloadManager.getLocalFile() 也落在那里 —— 那等于**把公放曲库整个删掉**。 - 后果链: 库空 → playRandomLocalSong() 里 allLocalSongs 为空 → RandomPlayQueue.next() 返回 null → 状态栏"本地暂无可播放歌曲"、currentSong 保持 null → 按「切歌」时 advancePlayback 落进「取消随机续播、进入空闲态」分支 → 按钮无反应。 (RandomPlayQueue.next 本身有"只剩一首也能播"的兜底,队列逻辑没问题。) 修复: - 删除整目录清理,改为 purgeInjectedAdDuplicatesOnce(): **只删能证明是注入广告的文件** —— 广告对每首歌下发同一个视频,特征是"多首不同歌曲的本地文件内容完全相同" (按文件大小 + 头部 4MB 指纹分组,只清理 size>=2 的完全相同组)。 正常歌曲内容各不相同,绝不会被误删;公放曲库得以保留。 同时按用户要求,复查"老设备适配期间改动对新设备的影响",另修正两处: - SongApiClient.getSongDownloadUrl 的主线程分支: 1.4.7 起会先走 resolveNatively() (最多 7 个源 × 8 秒超时的阻塞网络请求)—— 一旦有主线程调用者就会卡死 UI 到 ANR。 现在主线程只做本地 DB 兜底,网络取址一律留在后台线程。 - KtvJsBridge 裸 Socket 的 HTTPS 分支: 1.4.6 改用了无参 createSocket(),拿到的 SSLSocket **不带主机名**(影响 SNI 与证书主机名校验,共享 CDN/反代多证书时会握手失败)。 改为先 connect 普通 socket(保留显式连接超时)再 createSocket(plain, host, port, true)。 - buildDownloadUrl 的文件名比对只在点歌编号"可信"时启用: 只有从 song.filename 推出的编号才 参与比对;song.id 可能是数据库主键,拿它比对会误杀正常地址(真源被判"文件名不符")。 (另已实测 7 个源: 可用的 5 个真源 URL 文件名全部严格等于点歌编号,规则不会误杀。) 顺带记录用户给出的版本号约定(已写入 AGENTS.md): versionName = <major>.<minor>.<patch>,patch 0~10、minor 1~10; 1.4.10 之后是 1.5.0,1.10.1 之后是 2.1.0;versionCode 始终 +1。 版本 1.4.10(30) → 1.5.0(31);签名证书不变。测试 8 例全通过。
This commit is contained in:
@@ -77,40 +77,75 @@ object SongOkDownloadManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性清理历史遗留的歌曲缓存(广告漏放期间的产物)。
|
||||
* 一次性清理"同一段视频被当成多首不同歌曲"的注入广告。
|
||||
*
|
||||
* 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里
|
||||
* **完全没有过广告校验**,广告视频(如 `gz.ac16.vip`)会被当成歌曲下载并落盘;它又能通过
|
||||
* [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),因此**靠内容无法识别**,
|
||||
* **完全没有过广告校验**,广告视频(如 `gz.ac16.vip/ad_files/my_ad_video.ts`)会被当成歌曲
|
||||
* 下载并落盘;它又能通过 [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),
|
||||
* 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。
|
||||
*
|
||||
* 这类遗留文件只能在升级时清一次;此后由「取址即校验」(
|
||||
* [AdVideoFilter.isTrustedSongUrl])保证不再产生。清掉只是让歌曲重新下载,不影响任何用户数据。
|
||||
* ⚠️ **不能像 1.4.10 那样直接清空整个歌曲目录**:`MuseDatabase.songDirectories()` 只有
|
||||
* `cloudSongsDir` 一个目录,那等于把**公放曲库整个删掉** —— 库里没歌,
|
||||
* `playRandomLocalSong()` 选不出候选、`currentSong` 保持 null,
|
||||
* 于是「切歌」落进空闲态分支、按了没反应(新设备上的回归)。
|
||||
*
|
||||
* 这里改为**只删真正能证明是注入广告的文件**:广告对每首歌都下发**同一个视频文件**,
|
||||
* 所以它的特征是「多首不同歌曲的本地文件**内容完全相同**」。按「文件大小 + 头部指纹」
|
||||
* 分组,只清理 ≥2 个文件完全相同的组 —— 正常歌曲各有各的内容,绝不会被误删。
|
||||
*
|
||||
* @return 删除的文件数(已清理过则返回 0)
|
||||
*/
|
||||
@JvmStatic
|
||||
fun purgeLegacySongCacheOnce(context: android.content.Context): Int {
|
||||
val marker = File(context.filesDir, "song_cache_purged_v1")
|
||||
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 dir = File("${MuseDatabase.VIDEO_ROOT}/${MuseDatabase.CLOUD_SONG_DIR}")
|
||||
dir.listFiles()?.forEach { file ->
|
||||
if (file.isFile && file.extension.lowercase() in MEDIA_EXTENSIONS) {
|
||||
SongFileValidator.forget(file)
|
||||
if (file.delete()) removed++
|
||||
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", Charsets.UTF_8)
|
||||
}.onFailure { Log.w(TAG, "purge legacy song cache failed: ${it.message}") }
|
||||
// 首次启动才会真正有动作,之后 marker 存在会直接返回 0,不产生噪声日志。
|
||||
if (removed > 0 || !marker.exists()) {
|
||||
MiliLog.warn("SONG-AD 一次性清理历史歌曲缓存 removed=$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)
|
||||
@@ -211,10 +246,15 @@ object SongOkDownloadManager {
|
||||
}
|
||||
|
||||
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
|
||||
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
|
||||
// ⚠️ 只有「从文件名推出」的点歌编号才可信,才能参与 `.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.isTrustedSongUrl(cached, musicNo)) return cached
|
||||
if (AdVideoFilter.isTrustedSongUrl(cached, musicNoFromFile)) return cached
|
||||
// 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的
|
||||
// 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整),
|
||||
// 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。
|
||||
@@ -230,7 +270,7 @@ object SongOkDownloadManager {
|
||||
// ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退,
|
||||
// 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回
|
||||
// gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。
|
||||
if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNo)) {
|
||||
if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNoFromFile)) {
|
||||
song.downloadUrl = url
|
||||
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
|
||||
MiliLog.info(
|
||||
|
||||
Reference in New Issue
Block a user