依据用户提供的 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 例全过)
372 lines
18 KiB
Kotlin
372 lines
18 KiB
Kotlin
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(?:\\?|$)")
|
||
}
|