Files
maidong-ktv/app/src/main/java/com/local/ktv/SongUrlResolver.kt
T
zijunchiang 669766ba60 V1.4.10: 修复广告过滤失效(gz.ac16.vip 广告被当成歌曲下载播放)
现场事故(新设备 SM-S9180 / Android 16,WebView 正常 → 走 JS 取址):
  277  JS [KtvApi] URL OK: 7303235 ls=1 via http://w.w345.my
  278  点歌编号=7303235 标题=情歌(HD) 请求源=http://w.w345.my 状态=成功 urlHost=gz.ac16.vip
  279  PLAY id=7303235 title=情歌(HD) src=local
即 gz.ac16.vip 的广告视频被当成歌曲下载、缓存并播放。

三处漏洞叠加:
1. ktv_api.js 的 isDemoUrl 不认识广告域名;且"ts 文件名与编号比对"用的是
   /\/(\d+)\.ts/,只匹配纯数字名 —— my_ad_video.ts 这类非数字名字直接放行;
2. SongOkDownloadManager.buildDownloadUrl 的**成功分支完全没有广告校验**
   (AdVideoFilter.isAdVideo 全项目只在"缓存地址"与"原生解析"两处被调用);
3. SongApiClient.fetchFromDb 只做 isLikelyDemoUrl(纯 IP / r2.dev),挡不住广告域名。
且**内容校验挡不住广告**: 该广告通过 SongFileValidator(≥6MB/≥60s/TS 包完整),
被 isDownloaded() 一直复用,广告会反复播。

修复(单一闸门,全链路把关):
- AdVideoFilter 新增 isTrustedSongUrl(url, musicno):非 http(s)、命中广告特征
  (路径 /ad_files/、my_ad_video、**域名 *.ac16.vip**)、或 URL 里 .ts 文件名不等于
  点歌编号 → 一律不可信。真源恒为 /<musicno>.ts(已实测 txog.ktvsky.com 与
  download.origjoy.com 两个 CDN 域共 6 首全部相符)。
- 该闸门接入全部出入口: JS isDemoUrl(同步加固 + KTV_BRIDGE_API 8→9)、
  SongUrlResolver.isValidSongUrl、SongApiClient.fetchFromDb、
  以及最终兜底 SongOkDownloadManager.buildDownloadUrl(成功/缓存两条分支)。
- 判定不可信时**同时删除已落盘的歌曲文件**(purgeCachedSongFile),
  否则 isDownloaded() 会一直复用那个广告文件。
- 新增 SongOkDownloadManager.purgeLegacySongCacheOnce(): 升级后一次性清理历史遗留的
  可疑歌曲缓存(marker: filesDir/song_cache_purged_v1)。只是让歌曲重新下载,不动用户数据。
- 新增回归测试 AdVideoFilterTest(6 例,用日志里的真实 URL 形态,含两个真源 CDN 域);
  该测试当场抓到本修复自身的一个 bug:正则把扩展名也捕获进去导致真源被误杀,已修正。

版本 1.4.9(29) 到 1.4.10(30);签名证书不变。
2026-09-26 21:17:04 +08:00

226 lines
9.6 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
private val DEFAULT_HOSTS = listOf(
"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
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN
/**
* 最近一次取址命中的源 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)
}
}
}
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
if (musicno.isBlank()) return null
val channel = fetch ?: run {
MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
return null
}
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
val startedAt = System.currentTimeMillis()
val hosts = synchronized(hostOrder) { hostOrder.toList() }
var lastDetail = "no-source"
for ((index, host) in hosts.withIndex()) {
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
MiliLog.warn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
if (token.isNullOrEmpty()) {
lastDetail = "token-fail"
demote(host)
continue
}
val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) }
.getOrElse {
MiliLog.warn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
promote(host)
lastHost = host
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"
}
demote(host)
}
val elapsed = System.currentTimeMillis() - startedAt
MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms")
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
if (json.optInt("code") != 200) {
MiliLog.warn("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) {
MiliLog.warn("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) {
MiliLog.warn(
"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.warn("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(?:\\?|$)")
}