V1.4.7: 新增原生取址兜底 —— 修复 WebView 不可用的老盒子无法点歌
证据(用户导出的 1.4.6 远程日志): - 两条失败记录只有 Kotlin 侧那一行 「点歌编号=... 详情=无法生成下载地址」, 而 JS ...(本版新加的 console 桥)、RAW-ERR、NET-ERR、点歌失败 一条都没有。 - 即 JS 侧从未执行 —— 隐藏 WebView 在这台盒子上从未就绪 (每条失败前正好有约 15s 的「等桥就绪」超时,时间吻合)。 - 于是 SongApiClient 只能回退 DB 的 cloud_url,取不到就报「无法生成下载地址」。 根因: 取址完全依赖 WebView 里的 ktv_api.js;GMUI2.0/Android 5.1.1 上 WebView 不可用(无法导出 window.KtvApi),桥永远 not ready。 修复: - 新增 SongUrlResolver: 用 Kotlin 复刻 ktv_api.js 的协议 (GET <host>/i.php 取 token → GET <host>/music/do.php 取地址), 镜像多源排序(失败源沉底)与 isDemoUrl 校验(wb66/demo/纯IP/r2.dev/ts编号不一致)。 - 走与 JS 完全相同的裸 Socket「干净请求」通道(KtvJsBridge.rawCleanGet), 请求特征一致故风控表现一致,且不依赖 WebView 是否就绪。 - SongApiClient.getSongDownloadUrl 三处回退点改为: 桥未就绪 / JS 未取到地址 / 主线程调用 → 先走原生解析,再回退 DB。 - 补齐桥的生命周期可观测性: WebView 初始化失败、KtvApi 未导出、 15s 未就绪 均写入远程日志(以前只在 logcat,这就是本轮一开始没有线索的原因)。 已用 App 的真实身份( mac=800b510bf4fd / sn=2d7bb4a7d7f7 / VER=2.0 ) 从本机复现协议: i.php 200 → do.php code=200 且返回 http://txog.ktvsky.com/.../480p/7303235.ts (与点歌编号一致), 证明参数顺序与签名算法正确。 版本 1.4.6(26) 到 1.4.7(27);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
This commit is contained in:
@@ -160,7 +160,7 @@ $BT/apksigner verify 目标名.apk
|
|||||||
|
|
||||||
### 版本号
|
### 版本号
|
||||||
|
|
||||||
- `app/build.gradle`:`versionName "1.4.6"` / `versionCode 26`(18~21 被本机临时诊断包占用过)
|
- `app/build.gradle`:`versionName "1.4.7"` / `versionCode 27`(18~21 被本机临时诊断包占用过)
|
||||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||||
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
|
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
|
||||||
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
|
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
|
||||||
|
|||||||
+2
-2
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId "com.local.ktv"
|
applicationId "com.local.ktv"
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 25
|
targetSdk 25
|
||||||
versionCode 26
|
versionCode 27
|
||||||
versionName "1.4.6"
|
versionName "1.4.7"
|
||||||
ndk {
|
ndk {
|
||||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class KtvJsBridge(context: Context) {
|
|||||||
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
|
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
|
||||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||||
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
||||||
private const val DEVICE_SN = "2d7bb4a7d7f7"
|
internal const val DEVICE_SN = "2d7bb4a7d7f7"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断网重连后重置歌曲源会话。
|
* 断网重连后重置歌曲源会话。
|
||||||
@@ -98,6 +98,7 @@ class KtvJsBridge(context: Context) {
|
|||||||
updateCache(cached)
|
updateCache(cached)
|
||||||
} catch (error: Throwable) {
|
} catch (error: Throwable) {
|
||||||
Log.e(TAG, "WebView init failed", error)
|
Log.e(TAG, "WebView init failed", error)
|
||||||
|
MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
|
||||||
callback(false)
|
callback(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +139,7 @@ class KtvJsBridge(context: Context) {
|
|||||||
if (result == "true") {
|
if (result == "true") {
|
||||||
ready = true
|
ready = true
|
||||||
Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})")
|
Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})")
|
||||||
|
MiliLog.info("JS-BRIDGE ready (${if (loadedFromCache) "cache" else "asset"})")
|
||||||
callback(true)
|
callback(true)
|
||||||
} else if (loadedFromCache && !assetRetryAttempted) {
|
} else if (loadedFromCache && !assetRetryAttempted) {
|
||||||
Log.w(TAG, "Cached JS failed to load; falling back to packaged asset")
|
Log.w(TAG, "Cached JS failed to load; falling back to packaged asset")
|
||||||
@@ -146,6 +148,9 @@ class KtvJsBridge(context: Context) {
|
|||||||
createWebView(readAssetScript(), callback)
|
createWebView(readAssetScript(), callback)
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "KtvApi was not exported by JS")
|
Log.e(TAG, "KtvApi was not exported by JS")
|
||||||
|
// 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪,
|
||||||
|
// 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。
|
||||||
|
MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用")
|
||||||
callback(false)
|
callback(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,6 +163,18 @@ class KtvJsBridge(context: Context) {
|
|||||||
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
|
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
|
||||||
fun isReady(): Boolean = ready && !destroyed
|
fun isReady(): Boolean = ready && !destroyed
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供**原生兜底取址解析器**([SongUrlResolver])复用的「干净请求」通道。
|
||||||
|
*
|
||||||
|
* 与 JS 的 `android.httpGet` 走的是**完全同一条**裸 Socket 路径(只发 Host + Accept,
|
||||||
|
* 不带 UA/Connection/Accept-Encoding),因此请求特征与风控表现一致;
|
||||||
|
* 且**不依赖 WebView 是否就绪** —— 正是 WebView 不可用的老设备所需要的。
|
||||||
|
*/
|
||||||
|
fun rawCleanGet(url: String, timeoutMs: Int = 8_000): String {
|
||||||
|
val headers = JSONObject().put("Accept", "*/*").toString()
|
||||||
|
return NativeBridge().request("GET", url, null, headers, timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
fun isDestroyed(): Boolean = destroyed
|
fun isDestroyed(): Boolean = destroyed
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -396,7 +413,8 @@ class KtvJsBridge(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
|
/** 供 [rawCleanGet] 与 [NativeBridge.httpGet]/[httpPost] 共用的请求入口。 */
|
||||||
|
internal fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
|
||||||
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
|
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
|
||||||
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
|
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
|
||||||
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
|
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ object SongApiClient {
|
|||||||
fun init(context: Context) {
|
fun init(context: Context) {
|
||||||
if (bridge != null) return
|
if (bridge != null) return
|
||||||
bridge = KtvJsBridge(context)
|
bridge = KtvJsBridge(context)
|
||||||
|
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
|
||||||
|
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
|
||||||
|
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
|
||||||
bridge?.init { ok ->
|
bridge?.init { ok ->
|
||||||
ready = ok
|
ready = ok
|
||||||
Log.i(TAG, "JS Bridge init: $ok")
|
Log.i(TAG, "JS Bridge init: $ok")
|
||||||
|
if (!ok) MiliLog.error("JS-BRIDGE 初始化返回 false,取址将走原生兜底")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,10 +51,10 @@ object SongApiClient {
|
|||||||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
|
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
|
||||||
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
||||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||||
Log.w(TAG, "Synchronous JS request on main thread; using DB fallback")
|
Log.w(TAG, "Synchronous JS request on main thread; using native/DB fallback")
|
||||||
return fetchFromDb(musicno)
|
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||||
}
|
}
|
||||||
val bridge = bridge ?: return fetchFromDb(musicno)
|
val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||||
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
||||||
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
||||||
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
|
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
|
||||||
@@ -62,8 +66,11 @@ object SongApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!bridge.isReady()) {
|
if (!bridge.isReady()) {
|
||||||
Log.w(TAG, "Bridge not ready after wait, DB fallback")
|
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
|
||||||
return fetchFromDb(musicno)
|
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器。
|
||||||
|
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
||||||
|
MiliLog.warn("JS-BRIDGE 15s 未就绪,改用原生取址通道")
|
||||||
|
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||||
}
|
}
|
||||||
val result = AtomicReference<String?>()
|
val result = AtomicReference<String?>()
|
||||||
val latch = CountDownLatch(1)
|
val latch = CountDownLatch(1)
|
||||||
@@ -74,7 +81,9 @@ object SongApiClient {
|
|||||||
} catch (_: InterruptedException) {
|
} catch (_: InterruptedException) {
|
||||||
Thread.currentThread().interrupt()
|
Thread.currentThread().interrupt()
|
||||||
}
|
}
|
||||||
val url = result.get() ?: fetchFromDb(musicno)
|
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
|
||||||
|
// 避免 JS 侧偶发失败直接把第一首歌判死。
|
||||||
|
val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||||
if (url != null) {
|
if (url != null) {
|
||||||
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
|
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
|
||||||
Eac19WindowGuard.noteUrlOk()
|
Eac19WindowGuard.noteUrlOk()
|
||||||
@@ -82,9 +91,17 @@ object SongApiClient {
|
|||||||
return url
|
return url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
|
||||||
|
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
|
||||||
|
*/
|
||||||
|
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? =
|
||||||
|
runCatching { SongUrlResolver.resolve(musicno, resolution, h265) }
|
||||||
|
.onFailure { Log.w(TAG, "native resolver failed", it) }
|
||||||
|
.getOrNull()
|
||||||
|
|
||||||
/** DB cloud_url fallback — 必须通过 demo 检测才能使用 */
|
/** DB cloud_url fallback — 必须通过 demo 检测才能使用 */
|
||||||
private fun fetchFromDb(musicno: String): String? {
|
private fun fetchFromDb(musicno: String): String? { try {
|
||||||
try {
|
|
||||||
val db = MuseDatabase()
|
val db = MuseDatabase()
|
||||||
if (db.open()) {
|
if (db.open()) {
|
||||||
val url = db.getCloudUrl(musicno)
|
val url = db.getCloudUrl(musicno)
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装取址通道。
|
||||||
|
* @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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */
|
||||||
|
private fun isValidSongUrl(url: String, musicno: String): Boolean {
|
||||||
|
if (AdVideoFilter.isAdVideo(url)) 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
|
||||||
|
val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1)
|
||||||
|
if (matched != null && matched != musicno) 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(?:\\?|$)")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user