Files
maidong-ktv/app/src/main/java/com/local/ktv/SongUrlResolver.kt
T
zijunchiang 4576a89f9a 按 HAR 抓包对齐原版协议: 补 api.php 取配置 + 识别业务码 403 + 按试用配额限源
依据用户提供的 HAR(e.ac19.cn, Reqable): 原版客户端是三步
  1) POST /api.php  action=get_config&device_id=<mac>  (表单 + Dalvik UA)
  2) GET /i.php          参数与 App 完全一致
  3) GET /music/do.php   参数与 App 完全一致, 返回真源 CDN 地址

发现并修复三处差异:
1. 我们从未调 api.php(原版取址前必调)
   → KtvJsBridge.rawCleanPost(): 干净 POST + Content-Type 表单 + Dalvik UA
     (UA 由本机 Build.VERSION.RELEASE/MODEL/ID 拼出, 与 HAR 同形态)
   → SongUrlResolver.ensureConfig(): 缓存 30 分钟, 桥不可用则跳过
   → SongApiClient 注入 POST 通道
2. 风控拒绝的真实形态是 HTTP 200 + 业务码 403
   {"code":403,"msg":"请求被拒绝:检测到异常行为"} —— 不是 HTTP 403
   → i.php / do.php 两处业务码 403 都置 sawForbidden(诊断不再误判为"没有地址")
3. 无视试用配额: api.php 给出 trial_times=2 / trial_duration=5
   → 一次取址最多尝试 trial_times 个源(原先 9 个源全扫会把额度瞬间打光)

请求头分工与 HAR 一致: i.php/do.php 只有 Host+Accept 的干净 GET;
仅 api.php 带表单 Content-Type 与 Dalvik UA。
日志: 远程一条 SOURCE-CONFIG trial_enabled=… trial_times=…; 原始 JSON/卡密提示只写本地。

本机实测: api.php 返回与 HAR 相同配置; do.php 返回 code=403 检测到异常行为
(本出口 IP 处于风控/未授权: network_verification=true + 卡密机制, 额度按 IP 计)。

版本号按规则 3: 1.5.8(40) → 1.5.9(41); 按要求未打包
验证: compileReleaseKotlin + 单元测试(9 例全过)
2026-10-08 03:56:59 +08:00

372 lines
18 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.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 <host>/i.php`(取 token)→ `GET <host>/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<String> = 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(?:\\?|$)")
}