package com.local.ktv import android.util.Log import org.json.JSONObject import java.security.MessageDigest import java.util.Collections /** * 歌曲取址的**原生兜底实现**。 * * ## 为什么需要它 * 取址原本完全依赖隐藏 WebView 里的 `ktv_api.js`。但在一部分老电视盒子/投影上 * (实测:极米 H1S / GMUI2.0 / Android 5.1.1 / API 22)**WebView 不可用**: * `KtvJsBridge` 永远 `isReady()==false`,`SongApiClient` 等 15 秒后只能回退到数据库里的 * `cloud_url`,取不到就报「无法生成下载地址」。特征是**远程日志里连一条 `JS ...` 都没有** * (JS 从没跑起来)。 * * ## 做法 * 用 Kotlin 复刻 `ktv_api.js` 的同一套协议: * `GET /i.php`(取 token)→ `GET /music/do.php`(取播放地址), * 并镜像 JS 的多源排序(失败源沉底)与广告/错号资源校验(`isDemoUrl`)。 * * ⚠️ 它**必须走与 JS 相同的 HTTP 通道**([KtvJsBridge.rawCleanGet] 的裸 Socket, * 只发 Host + Accept),否则会被源站风控拒绝(带 UA/Connection 头会被判"异常行为")。 * * ⚠️ 这里的 APP_ID/APP_KEY/SDK_KEY/HOSTS 是 `ktv_api.js` CONFIG 的镜像。 * 源站配置若变更,JS 可热更新而这里需要发版 —— 因此它**只作为兜底**: * WebView 可用时仍以 JS 为准(见 [SongApiClient.getSongDownloadUrl])。 */ object SongUrlResolver { private const val TAG = "SongUrlResolver" // ── 以下与 assets/mobile/ktv_api.js 的 CONFIG 保持一致 ── // 注意 VER/VN 与 KtvJsBridge 注入 JS 时覆盖的值一致(不是 JS 文件里的原始值)。 private const val APP_ID = "d4eeacc6cec3434fbc8c41608a3056a0" private const val APP_KEY = "024210cba40d4385a93e6c2d3249bfb5" private const val SDK_KEY = "19042303a8374f67ae3fe1e25c97936f" private const val VER = "2.0" private const val VN = "4.1.3.03281430" private const val TIMEOUT_MS = 8_000 /** * "源站只发广告/测试资源"后的冷却时长。 * * 该限制记在**出口 IP** 上、与设备无关(实测换 mac/sn 无效),源站侧是约 10 分钟的试用窗口; * 这里冷却 3 分钟即可(够避免继续冲击源站,又不会拖太久错过窗口恢复)。 */ private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L /** `api.php` 配置缓存时长(原版每次启动/长时间后取一次即可)。 */ private const val CONFIG_TTL_MS = 30 * 60_000L 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://desktok.top", "http://mm.kk456.top", "http://w.w345.my:66", "http://z.w345.my", "http://b.sktv.top", "http://w345.my", ) /** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */ private val hostOrder: MutableList = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList()) @Volatile private var fetch: ((String) -> String?)? = null /** 干净 POST 通道(`api.php` 取配置用;未注入时跳过配置拉取)。 */ @Volatile private var post: ((String, String) -> String?)? = null /** * 源站配置(原版客户端取址前会 POST `api.php` 的 `action=get_config` 拿到)。 * * 实测(HAR 2026-10-08):`{"trial_enabled":true,"trial_times":2,"trial_duration":5, * "network_verification":true,"heartbeat_interval":3600,"dialog_title":"请输入商家提供的正确卡密"}` * —— **试用额度只有 2 次 / 5 分钟**,所以取址**不能把 9 个源全扫一遍**(会把额度瞬间打光, * 之后就一直 `code=403 检测到异常行为`)。 */ private data class SourceConfig( val trialTimes: Int, val trialDurationMin: Int, val networkVerification: Boolean, ) @Volatile private var config: SourceConfig? = null @Volatile private var configAt: Long = 0L @Volatile private var sn: String = KtvJsBridge.DEVICE_SN /** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */ @Volatile private var sawForbidden = false /** 被判定为"源站只发广告/测试资源"后的冷却截止时间(0 = 未受限)。 */ @Volatile private var blockedUntil: Long = 0L /** * 最近一次取址命中的源 host。 * 供 `SongApiClient.lastSongSourceHost()` 使用 —— 走原生兜底时 JS 的 * `reportSongResult` 不会触发,否则日志里「请求源=」永远是空的。 */ @Volatile var lastHost: String = "" private set /** * 安装取址通道。 * @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]); * 入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。 */ fun install(fetch: (String) -> String?) { this.fetch = fetch Log.i(TAG, "native song url resolver installed (${DEFAULT_HOSTS.size} hosts)") } private fun demote(host: String) { synchronized(hostOrder) { val index = hostOrder.indexOf(host) if (index >= 0 && index < hostOrder.size - 1) { hostOrder.removeAt(index) hostOrder.add(host) } } } private fun promote(host: String) { synchronized(hostOrder) { val index = hostOrder.indexOf(host) if (index > 0) { hostOrder.removeAt(index) hostOrder.add(0, host) } } } /** 注入"干净 POST"通道(`api.php` 取配置)。 */ fun installPost(post: (String, String) -> String?) { this.post = post } /** * 拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。 * * 用途:① 日志里给出"是否启用试用/试用次数/时长/是否需要卡密"这些**结论**; * ② 按 `trial_times` 限制一次取址尝试的源数量,避免把试用额度瞬间耗尽。 */ private fun ensureConfig(host: String, mac: String) { if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return val channel = post ?: return val form = "action=get_config&device_id=$mac" val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return val status = envelope.optInt("status") if (status !in 200..299) { MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status") return } val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return val c = body.optJSONObject("config") ?: body val times = c.optInt("trial_times", 0) val dur = c.optInt("trial_duration", 0) val verify = c.optBoolean("network_verification", false) config = SourceConfig(times, dur, verify) configAt = System.currentTimeMillis() // 远程只留一条"结论";完整 JSON 与卡密提示只写本地。 MiliLog.info( "SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " + "trial_times=$times trial_duration=${dur}min network_verification=$verify " + "heartbeat=${c.optInt("heartbeat_interval", 0)}s", ) MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}") if (verify && c.optString("dialog_title").isNotEmpty()) { MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}") } } /** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */ fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? { if (musicno.isBlank()) return null val channel = fetch ?: run { MiliLog.localWarn("NATIVE-SONG 未安装取址通道,跳过原生兜底") return null } // 已判定"源站对该出口 IP 只发广告/测试资源"时进入冷却:直接失败, // 既让用户立刻看到结论,也避免继续冲击源站把限流拖得更久。 val nowMs = System.currentTimeMillis() if (nowMs < blockedUntil) { MiliLog.localWarn( "SOURCE-BLOCKED 冷却中(剩 ${(blockedUntil - nowMs) / 1000}s)跳过取址 编号=$musicno", ) return null } sawForbidden = false val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn) val startedAt = System.currentTimeMillis() val allHosts = synchronized(hostOrder) { hostOrder.toList() } // 原版取址前会先 POST api.php 取配置;这里顺带用它限制一次取址的源数量 // (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。 ensureConfig(allHosts.firstOrNull().orEmpty(), mac) val maxHosts = config?.trialTimes?.takeIf { it > 0 }?.coerceAtMost(allHosts.size) ?: allHosts.size if (maxHosts < allHosts.size) { MiliLog.local("NATIVE-SONG 按试用额度限制本次取址源数=$maxHosts(共 ${allHosts.size} 个)") } val hosts = allHosts.take(maxHosts) var lastDetail = "no-source" var adOnlyCount = 0 // 返回了地址但被判为广告/测试资源的源数 var otherFailCount = 0 // token 失败 / 无地址 / 网络异常的源数 var adSample = "" for ((index, host) in hosts.withIndex()) { val token = runCatching { fetchToken(channel, host, mac) }.getOrElse { MiliLog.localWarn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") null } if (token.isNullOrEmpty()) { lastDetail = "token-fail" otherFailCount++ demote(host) continue } val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) } .getOrElse { MiliLog.localWarn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}") null } if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) { promote(host) lastHost = host blockedUntil = 0L // 拿到真源 → 立刻解除冷却 val elapsed = System.currentTimeMillis() - startedAt val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?" MiliLog.info( "NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " + "第${index + 1}个源 用时=${elapsed}ms", ) return url } lastDetail = when { url.isNullOrEmpty() -> "no-url" else -> "demo-or-mismatch" } if (url.isNullOrEmpty()) { otherFailCount++ } else { adOnlyCount++ if (adSample.isEmpty()) adSample = url } demote(host) } val elapsed = System.currentTimeMillis() - startedAt // ⚠️ 源站 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 // 远程只留这一条汇总;逐源细节与示例 URL 只写本地。 MiliLog.error( "SOURCE-BLOCKED 全部 $adOnlyCount 个源只返回广告/测试资源(该出口 IP 授权窗口失效)" + " 编号=$musicno 用时=${elapsed}ms", ) MiliLog.localError("SOURCE-BLOCKED 示例广告地址=$adSample") Eac19WindowGuard.noteRiskBlocked() } else { MiliLog.error( "NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms saw403=$sawForbidden", ) // 全都 403 = 源站明确风控(另一种受限形态,与"只发广告"并列),同样引导换公网 IP。 if (sawForbidden) { blockedUntil = System.currentTimeMillis() + BLOCKED_COOLDOWN_MS MiliLog.localError("SOURCE-BLOCKED 源站返回 HTTP 403(风控/授权窗口失效)编号=$musicno") Eac19WindowGuard.noteRiskBlocked() } } // 本轮取址彻底失败(无论原因是 403、只返回广告、还是网络异常): // 发出"全部源失效"总信号,让界面明确告知用户,而不是看起来没反应。 Eac19WindowGuard.noteAllSourcesFailed() return null } // ─── 协议实现 ─────────────────────────────────────── private fun fetchToken(channel: (String) -> String?, host: String, mac: String): String? { val ts = System.currentTimeMillis() / 1000 val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN" val sign = md5(params + APP_KEY) val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null val tokenCode = json.optInt("code") if (tokenCode == 403) sawForbidden = true // 业务码 403 = 风控(见 do.php 同处注释) if (tokenCode != 200) { MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}") return null } return json.optString("token").takeIf { it.isNotEmpty() } } private fun fetchSongUrl( channel: (String) -> String?, host: String, mac: String, musicno: String, token: String, resolution: String, h265: Boolean, ): String? { val ts = System.currentTimeMillis() / 1000 // 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 一致(同 ktv_api.js)。 val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" + "&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token" val sign = md5(params + SDK_KEY) val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null val code = json.optInt("code") if (code != 200) { // ⚠️ 源站的"风控拒绝"是 **HTTP 200 + 业务码 403**(body: // `{"code":403,"msg":"请求被拒绝:检测到异常行为"}`,2026-10-08 实测), // 不是 HTTP 403 —— 以前只按 HTTP 状态判断,这类响应被当成"没有地址", // 日志里看不出真实原因。 if (code == 403) sawForbidden = true MiliLog.localWarn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}") return null } return json.optString("data").takeIf { it.isNotEmpty() } } /** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */ private fun getJson(channel: (String) -> String?, url: String): JSONObject? { val raw = channel(url) ?: return null val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null val status = envelope.optInt("status") if (status !in 200..299) { // 403 = 源站风控的直接信号(2026-10 实测主源 w.w345.my/i.php 就是 403)。 // 记下来,全部源都失败时据此走 Eac19WindowGuard 的"换公网 IP"提示。 if (status == 403) sawForbidden = true MiliLog.localWarn( "NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " + "err=${envelope.optString("error").take(80)}", ) return null } val body = envelope.optString("body") if (body.isBlank()) return null return runCatching { JSONObject(body) }.getOrElse { MiliLog.localWarn("NATIVE-SONG 响应非 JSON: ${body.take(120)}") null } } /** * 原生路径的可信校验:先过 `AdVideoFilter.isTrustedSongUrl`(广告域名/路径 + `.ts` 文件名 * 必须等于点歌编号),再补上 JS `isDemoUrl` 里那几条(wb66/demo、纯 IP、R2 测试桶)。 */ private fun isValidSongUrl(url: String, musicno: String): Boolean { 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 return true } private fun md5(text: String): String = MessageDigest.getInstance("MD5").digest(text.toByteArray(Charsets.UTF_8)) .joinToString("") { "%02x".format(it) } private val IP_URL = Regex("^https?://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?::\\d+)?/", RegexOption.IGNORE_CASE) private val R2_DEMO = Regex("pub-[a-z0-9]+\\.r2\\.dev/ss/", RegexOption.IGNORE_CASE) private val TS_NAME = Regex("/(\\d+)\\.ts(?:\\?|$)") }