Files
maidong-ktv/app/src/main/java/com/local/ktv/SongApiClient.kt
T
zijunchiang eadc2d4de1 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 例全通过。
2026-09-26 21:25:37 +08:00

160 lines
7.5 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.local.ktv
import android.content.Context
import android.os.Looper
import android.util.Log
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* 歌曲 API 客户端 —— JS 热更新桥接
* =================================
* - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
* - API 变更时只需更新 Gitea JS 文件,App 无需发版
* - 首次播放时实时获取 URL,不存在过期问题
*/
object SongApiClient {
private const val TAG = "SongApiClient"
private var bridge: KtvJsBridge? = null
@Volatile private var ready = false
@JvmStatic
fun init(context: Context) {
if (bridge != null) return
bridge = KtvJsBridge(context)
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
bridge?.init { ok ->
ready = ok
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
Log.i(TAG, "JS Bridge init: $ok")
}
}
/** 兼容旧接口 */
@JvmStatic
fun init(sn: String, mac: String) {
// JS 脚本自己生成 MAC/SN
}
@JvmStatic
fun getAuthToken(): String = ""
@JvmStatic
fun getSongDownloadUrl(musicno: String?): String? =
musicno?.takeIf(String::isNotBlank)?.let { getSongDownloadUrl(it, "720", false) }
@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 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。
val deadline = System.currentTimeMillis() + 15_000L
while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) {
try { Thread.sleep(100) } catch (_: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
if (!bridge.isReady()) {
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器,
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
Log.w(TAG, "Bridge not ready after wait, native resolver")
bridge.markUnavailable("15s 未就绪")
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
}
val result = AtomicReference<String?>()
val latch = CountDownLatch(1)
bridge.getSongUrl(musicno, resolution, h265) { url -> result.set(url); latch.countDown() }
// JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。
try {
latch.await(185, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
// 避免 JS 侧偶发失败直接把第一首歌判死。
val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
if (url != null) {
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
Eac19WindowGuard.noteUrlOk()
}
return url
}
/**
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
*/
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? =
runCatching { SongUrlResolver.resolve(musicno, resolution, h265) }
.onFailure { Log.w(TAG, "native resolver failed", it) }
.getOrNull()
/** DB cloud_url fallback — 必须通过 demo 检测才能使用 */
private fun fetchFromDb(musicno: String): String? { try {
val db = MuseDatabase()
if (db.open()) {
val url = db.getCloudUrl(musicno)
db.close()
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
return url
}
}
} catch (_: Exception) {}
return null
}
/** 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底) */
private fun isLikelyDemoUrl(url: String): Boolean {
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return true
// 纯 IP 地址 → 高概率 demo
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"))) return true
// Cloudflare R2 demo bucket (pub-*.r2.dev)
if (host.matches(Regex("^pub-[a-z0-9]+\\.r2\\.dev$"))) return true
return false
}
/**
* 重置歌曲源会话(清 JS 侧 token / API 实例)。
*
* 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是
* 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。
*/
@JvmStatic
fun clearTokenCache() {
bridge?.resetApiSession()
}
/** 语义更明确的别名:断网恢复后重建歌曲源会话。 */
@JvmStatic
fun resetSourceSession() {
bridge?.resetApiSession()
}
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
@JvmStatic
fun lastSongSourceHost(): String =
bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost
}