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 不可用时唯一能取到地址的路径。 // 第二参是自建源的 App 令牌(官方源传 null,请求形态与改造前逐字节一致)。 SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) } // api.php 取配置走 POST(原版形态);桥不可用时为 null,此时跳过配置拉取。 SongUrlResolver.installPost { url, form -> bridge?.rawCleanPost(url, form) } // App 令牌(自建源 `GET /token`)也走同一条干净 GET:与取址共用缓存与"该源不支持"记忆。 AppTokenClient.install { url -> bridge?.rawCleanGet(url, null, AppToken.TIMEOUT_MS) } 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? = getSongDownloadUrl(musicno, resolution, h265, "") /** * 取址(**四条路径唯一的汇合点**)+ 「App 点歌推送」切入点。 * * 为什么把推送挂在这里(而不是 JS/原生各自的成功分支):能拿到下载地址的路径有四条 * (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了 * 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。 * * ⚠️ **只有"地址来自官方源"才推送**(2026-10-14 新流程):官方源成功 → 把该下载地址 * 上报自建服务器(`POST /push_download`,让它留存并入库这首);地址来自**自建源**时不推 * (服务器本来就有这首),DB 兜底同样不推。判定用的是取址实现**显式返回**的来源标志 * ([SourcedSongUrl.sourceHost],见 [SongSourceFlow.shouldReportToSelfHost]), * **不靠 URL 字符串猜、也不读可变全局**。 * * 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、 * 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。 * * @param songName 歌名(可选,仅用于推送日志与服务器核对,不入库) */ @JvmStatic fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? { val resolved = resolveSongDownloadUrl(musicno, resolution, h265) val url = resolved?.url if (!url.isNullOrEmpty() && musicno.isNotBlank() && SongSourceFlow.shouldReportToSelfHost(resolved) ) { runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) } .onFailure { Log.w(TAG, "push download skipped", it) } } return url } /** * 真正的取址实现(推送不参与其逻辑,见上面的入口)。 * * 返回 [SourcedSongUrl](= 地址 + **产出它的源**)而不是裸字符串:汇合点据此判定 * "是否上报自建服务器"(见 [getSongDownloadUrl])。回落链 * JS 主路径 → 原生兜底 → DB 回退由 [SongSourceFlow.firstAvailable] 表达。 */ private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? { // 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源 if (musicno.isBlank()) return null // 主线程同步等待 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 SongSourceFlow.firstAvailable( { resolveNatively(musicno, resolution, h265) }, { fetchFromDb(musicno) }, ) // 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。 // (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。) if (bridge.isUnavailable()) { return SongSourceFlow.firstAvailable( { 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 SongSourceFlow.firstAvailable( { resolveNatively(musicno, resolution, h265) }, { fetchFromDb(musicno) }, ) } val result = AtomicReference() val latch = CountDownLatch(1) bridge.getSongUrl(musicno, resolution, h265) { url, sourceHost -> // JS 同时回传"本次命中的源"(第二参)→ 构造**显式来源标志**。 result.set(url?.takeIf { it.isNotEmpty() }?.let { SourcedSongUrl(it, sourceHost.orEmpty()) }) latch.countDown() } // JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。 try { latch.await(185, TimeUnit.SECONDS) } catch (_: InterruptedException) { Thread.currentThread().interrupt() } // 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会, // 避免 JS 侧偶发失败直接把第一首歌判死。 val resolved = SongSourceFlow.firstAvailable( { result.get() }, { resolveNatively(musicno, resolution, h265) }, { fetchFromDb(musicno) }, ) if (resolved != null) { // 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。 Eac19WindowGuard.noteUrlOk() } return resolved } /** * 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。 * 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。 */ private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? = runCatching { SongUrlResolver.resolveSourced(musicno, resolution, h265) } .onFailure { Log.w(TAG, "native resolver failed", it) } .getOrNull() /** * DB `cloud_url` 回落:必须通过 demo 检测与地址闸门才能使用。 * * 返回的 [SourcedSongUrl.sourceHost] 是**空串** —— DB 里的地址既不是官方源也不是自建源 * 现取的,因此 [SourcedSongUrl.fromOfficialSource] 为 false → **不上报** * (符合"只有官方源成功才上报")。 * * ⚠️ 这里**不再静默吞异常**(隐患 5):DB 打不开 / 查询抛异常与"DB 里没有 cloud_url" * 以前外观完全一样,现场根本分不出是"曲库坏了"还是"这首歌没有云地址"。 * 现在前者各记一条 `MiliLog.localWarn`(含编号与异常信息)。 */ private fun fetchFromDb(musicno: String): SourcedSongUrl? { return try { val db = MuseDatabase() if (!db.open()) { MiliLog.localWarn("SONG-DB 曲库未就绪,跳过 cloud_url 回落 编号=$musicno") return null } val url = db.getCloudUrl(musicno) db.close() // DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验 // (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。 if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) { SourcedSongUrl(url, "") } else { null } } catch (e: Exception) { MiliLog.localWarn( "SONG-DB 查询 cloud_url 异常 编号=$musicno ${e.javaClass.simpleName}: ${e.message}", ) null } } /** * 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底)。 * * ⚠️ host 一律用 [AdVideoFilter.hostOf](**已剥离 query/fragment,且 URI 解析失败会退化**): * 旧实现直接 `java.net.URI(url).host`,query 里出现 `|`/空格这类字符时 `URI()` 抛异常, * 被 `?: return true` 当成 demo —— 合法真源地址会因此被拒("取址失败"的隐蔽来源)。 * * ⚠️ 裸 IP 不再一律判 demo:命中媒体地址允许清单([AdVideoFilter.isAllowedMediaHost])时 * 放行 —— 直连内网自建源时 DB 里的 `cloud_url` 就是裸 IP。 */ private fun isLikelyDemoUrl(url: String): Boolean { val host = AdVideoFilter.hostOf(url) ?: return true // 纯 IP 地址 → 高概率 demo(**允许清单里的主机除外**) if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")) && !AdVideoFilter.isAllowedMediaHost(host)) 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 会话(隐患 1)。 * * 原生侧由 [SongUrlResolver.applySourceHosts] 立刻生效;JS 侧过去只在下一次"重建会话" * (`window.api == null`)时才拿到新列表,已构造的 `window.api.sourceOrder` 仍是旧顺序 —— * 两条取址路径会在同一时刻用**不同的源顺序**。现在保存设置后直接重新注入 * (`KtvApiConfig.*` + `window.api.sourceOrder`),两端立刻一致。 */ @JvmStatic fun applySourceSettings() { bridge?.applySourceSettingsNow() } /** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */ @JvmStatic fun lastSongSourceHost(): String = bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost }