需求一:流程反转 + 上报绑定官方源成功 - 默认 sourceHosts 反转为 ["http://zk.ktv1.top:6677", "https://mdmuse.kasugano.cn"]; SourceHosts.DEFAULT / SongUrlResolver.DEFAULT_HOSTS(收敛为引用前者)/ ktv_api.js CONFIG.HOSTS+HOST 三处一致。 - 取址结果改为 SourcedSongUrl(url, sourceHost)(**显式来源标志**,不靠 URL 猜): 新增 SongSourceFlow(回落链 firstAvailable / 上报判定 shouldReportToSelfHost / 全失败信号);JS 侧新增 android.onSongSource(requestId, host) 回传命中的源。 - 汇合点 SongApiClient.getSongDownloadUrl 只在"地址来自官方源"时才 POST /push_download; 自建源自己给的地址与 DB 回落都不上报。 - 推送默认目标改为"从歌曲源列表里找第一个自建源"(自建源不再排第一,旧写法会直接关掉推送)。 - 1.6.1 落盘的旧默认顺序(自建源优先)在 KtvStore.load() 里一次性迁移;自定义列表不动。 - 业务码 404(自建源没有这首歌)仍只当"该源没地址",不置 sawForbidden、不进冷却、不污染受限统计。 需求二:6 条隐患 1) 设置页改源对 JS 立即生效:KtvJsBridge.applySourceSettingsNow 重新注入 KtvApiConfig.* 并把已构造实例的 window.api.sourceOrder 重置为新列表;SongApiClient.applySourceSettings 接线。 2) JS 路径识别业务码 403:新增 KtvJsBridge.riskForbidden(status, body)(纯函数,手写判定 避免 org.json 在单测里是空壳),HTTP 403 与 HTTP 200+{"code":403} 都算风控,与原生 sawForbidden 同口径。 3) isTrustedDownloadUrl 移除广告域名 ac16.vip 白名单,并改为黑名单优先(不再依赖调用顺序)。 4) MainActivity.download(song)(第五条地址消费点)补 AdVideoFilter.isTrustedSongUrl 闸门, 不可信按"源失效"处理(Eac19WindowGuard.noteAllSourcesFailed),不再静默下载。 5) SongApiClient.fetchFromDb 不再静默吞异常:曲库打不开/查询异常各记一条 MiliLog.localWarn(含编号与异常)。 6) 删除死代码 streamDownloadAndPlay(全仓 grep 无调用点,且内部取址不过最终闸门)。 需求三:死代码/漂移清理(均先全仓搜索确认无引用) - AdVideoFilter.replacementUrl 无任何写入点(恒为 null)→ 删除,resolve() 只保留"屏蔽"语义。 - ktv_api.js CONFIG.VER/VN 由"从不生效的死值"改为实际使用值 2.0 / 4.1.3.03281430。 - CONFIG.HOST 改为被 getToken 真正读取(不再"注入但从未被读")。 - KTV_BRIDGE_API 14 → 15(JS 两处 + KtvJsBridge 两处校验);AGENTS.md / README.md / README.en.md 的陈旧标记统一到 15。 - do.php 的 limit 与 i.php 的 retry/ordertype/end_date/heart_time 确认无消费方 → 不新增解析(保持现状)。 需求四:测试与打包 - 新增 SourceOrderConsistencyTest / SongSourceFlowTest / JsBridgeGateTest / DownloadEntryGateTest, 更新 SourceHostsTest / AdVideoFilterTest;app/build.gradle 打开 unitTests.returnDefaultValues。 - testReleaseUnitTest --rerun-tasks --offline:12 个测试类 / 87 例全过。 - assembleRelease --offline 通过(lintVital NewApi 0 条);版本 1.6.1/44 → 1.6.2/45。 不发布:未推 Gitea/Gitee、未动 Release/tag。
268 lines
13 KiB
Kotlin
268 lines
13 KiB
Kotlin
package com.local.ktv
|
||
|
||
import android.content.Context
|
||
import android.os.Looper
|
||
import android.util.Log
|
||
import java.util.concurrent.CountDownLatch
|
||
import java.util.concurrent.TimeUnit
|
||
import java.util.concurrent.atomic.AtomicReference
|
||
|
||
/**
|
||
* 歌曲 API 客户端 —— JS 热更新桥接
|
||
* =================================
|
||
* - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
|
||
* - API 变更时只需更新 Gitea JS 文件,App 无需发版
|
||
* - 首次播放时实时获取 URL,不存在过期问题
|
||
*/
|
||
object SongApiClient {
|
||
private const val TAG = "SongApiClient"
|
||
|
||
private var bridge: KtvJsBridge? = null
|
||
@Volatile private var ready = false
|
||
|
||
@JvmStatic
|
||
fun init(context: Context) {
|
||
if (bridge != null) return
|
||
bridge = KtvJsBridge(context)
|
||
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
|
||
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
|
||
// 第二参是自建源的 App 令牌(官方源传 null,请求形态与改造前逐字节一致)。
|
||
SongUrlResolver.install { url, appToken -> bridge?.rawCleanGet(url, appToken) }
|
||
// api.php 取配置走 POST(原版形态);桥不可用时为 null,此时跳过配置拉取。
|
||
SongUrlResolver.installPost { url, form -> bridge?.rawCleanPost(url, form) }
|
||
// App 令牌(自建源 `GET /token`)也走同一条干净 GET:与取址共用缓存与"该源不支持"记忆。
|
||
AppTokenClient.install { url -> bridge?.rawCleanGet(url, null, AppToken.TIMEOUT_MS) }
|
||
bridge?.init { ok ->
|
||
ready = ok
|
||
// 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。
|
||
Log.i(TAG, "JS Bridge init: $ok")
|
||
}
|
||
}
|
||
|
||
/** 兼容旧接口 */
|
||
@JvmStatic
|
||
fun init(sn: String, mac: String) {
|
||
// JS 脚本自己生成 MAC/SN
|
||
}
|
||
|
||
@JvmStatic
|
||
fun getAuthToken(): String = ""
|
||
|
||
@JvmStatic
|
||
fun getSongDownloadUrl(musicno: String?): String? =
|
||
musicno?.takeIf(String::isNotBlank)?.let { getSongDownloadUrl(it, "720", false) }
|
||
|
||
@JvmStatic
|
||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? =
|
||
getSongDownloadUrl(musicno, resolution, h265, "")
|
||
|
||
/**
|
||
* 取址(**四条路径唯一的汇合点**)+ 「App 点歌推送」切入点。
|
||
*
|
||
* 为什么把推送挂在这里(而不是 JS/原生各自的成功分支):能拿到下载地址的路径有四条
|
||
* (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了
|
||
* 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。
|
||
*
|
||
* ⚠️ **只有"地址来自官方源"才推送**(2026-10-14 新流程):官方源成功 → 把该下载地址
|
||
* 上报自建服务器(`POST /push_download`,让它留存并入库这首);地址来自**自建源**时不推
|
||
* (服务器本来就有这首),DB 兜底同样不推。判定用的是取址实现**显式返回**的来源标志
|
||
* ([SourcedSongUrl.sourceHost],见 [SongSourceFlow.shouldReportToSelfHost]),
|
||
* **不靠 URL 字符串猜、也不读可变全局**。
|
||
*
|
||
* 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、
|
||
* 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。
|
||
*
|
||
* @param songName 歌名(可选,仅用于推送日志与服务器核对,不入库)
|
||
*/
|
||
@JvmStatic
|
||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? {
|
||
val resolved = resolveSongDownloadUrl(musicno, resolution, h265)
|
||
val url = resolved?.url
|
||
if (!url.isNullOrEmpty() && musicno.isNotBlank() &&
|
||
SongSourceFlow.shouldReportToSelfHost(resolved)
|
||
) {
|
||
runCatching { PushDownloadClient.offer(musicno.trim(), url, songName) }
|
||
.onFailure { Log.w(TAG, "push download skipped", it) }
|
||
}
|
||
return url
|
||
}
|
||
|
||
/**
|
||
* 真正的取址实现(推送不参与其逻辑,见上面的入口)。
|
||
*
|
||
* 返回 [SourcedSongUrl](= 地址 + **产出它的源**)而不是裸字符串:汇合点据此判定
|
||
* "是否上报自建服务器"(见 [getSongDownloadUrl])。回落链
|
||
* JS 主路径 → 原生兜底 → DB 回退由 [SongSourceFlow.firstAvailable] 表达。
|
||
*/
|
||
private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? {
|
||
// 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源
|
||
if (musicno.isBlank()) return null
|
||
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
||
// ⚠️ 主线程**绝不能**走网络路径:原生兜底最多 7 个源 × 8 秒超时,会把 UI 线程
|
||
// 卡死到 ANR(1.4.7 曾把这里改成先 resolveNatively 再回退,属于隐患)。
|
||
// 主线程只做本地的 DB 兜底,网络取址一律留给后台线程。
|
||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
|
||
return fetchFromDb(musicno)
|
||
}
|
||
val bridge = bridge
|
||
?: return SongSourceFlow.firstAvailable(
|
||
{ resolveNatively(musicno, resolution, h265) },
|
||
{ fetchFromDb(musicno) },
|
||
)
|
||
// 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。
|
||
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
|
||
if (bridge.isUnavailable()) {
|
||
return SongSourceFlow.firstAvailable(
|
||
{ resolveNatively(musicno, resolution, h265) },
|
||
{ fetchFromDb(musicno) },
|
||
)
|
||
}
|
||
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
||
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
||
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
|
||
val deadline = System.currentTimeMillis() + 15_000L
|
||
while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) {
|
||
try { Thread.sleep(100) } catch (_: InterruptedException) {
|
||
Thread.currentThread().interrupt()
|
||
break
|
||
}
|
||
}
|
||
if (!bridge.isReady()) {
|
||
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
|
||
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器,
|
||
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
|
||
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
||
bridge.markUnavailable("15s 未就绪")
|
||
return SongSourceFlow.firstAvailable(
|
||
{ resolveNatively(musicno, resolution, h265) },
|
||
{ fetchFromDb(musicno) },
|
||
)
|
||
}
|
||
val result = AtomicReference<SourcedSongUrl?>()
|
||
val latch = CountDownLatch(1)
|
||
bridge.getSongUrl(musicno, resolution, h265) { url, sourceHost ->
|
||
// JS 同时回传"本次命中的源"(第二参)→ 构造**显式来源标志**。
|
||
result.set(url?.takeIf { it.isNotEmpty() }?.let { SourcedSongUrl(it, sourceHost.orEmpty()) })
|
||
latch.countDown()
|
||
}
|
||
// JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。
|
||
try {
|
||
latch.await(185, TimeUnit.SECONDS)
|
||
} catch (_: InterruptedException) {
|
||
Thread.currentThread().interrupt()
|
||
}
|
||
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
|
||
// 避免 JS 侧偶发失败直接把第一首歌判死。
|
||
val resolved = SongSourceFlow.firstAvailable(
|
||
{ result.get() },
|
||
{ resolveNatively(musicno, resolution, h265) },
|
||
{ fetchFromDb(musicno) },
|
||
)
|
||
if (resolved != null) {
|
||
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
|
||
Eac19WindowGuard.noteUrlOk()
|
||
}
|
||
return resolved
|
||
}
|
||
|
||
/**
|
||
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
|
||
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
|
||
*/
|
||
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? =
|
||
runCatching { SongUrlResolver.resolveSourced(musicno, resolution, h265) }
|
||
.onFailure { Log.w(TAG, "native resolver failed", it) }
|
||
.getOrNull()
|
||
|
||
/**
|
||
* DB `cloud_url` 回落:必须通过 demo 检测与地址闸门才能使用。
|
||
*
|
||
* 返回的 [SourcedSongUrl.sourceHost] 是**空串** —— DB 里的地址既不是官方源也不是自建源
|
||
* 现取的,因此 [SourcedSongUrl.fromOfficialSource] 为 false → **不上报**
|
||
* (符合"只有官方源成功才上报")。
|
||
*
|
||
* ⚠️ 这里**不再静默吞异常**(隐患 5):DB 打不开 / 查询抛异常与"DB 里没有 cloud_url"
|
||
* 以前外观完全一样,现场根本分不出是"曲库坏了"还是"这首歌没有云地址"。
|
||
* 现在前者各记一条 `MiliLog.localWarn`(含编号与异常信息)。
|
||
*/
|
||
private fun fetchFromDb(musicno: String): SourcedSongUrl? {
|
||
return try {
|
||
val db = MuseDatabase()
|
||
if (!db.open()) {
|
||
MiliLog.localWarn("SONG-DB 曲库未就绪,跳过 cloud_url 回落 编号=$musicno")
|
||
return null
|
||
}
|
||
val url = db.getCloudUrl(musicno)
|
||
db.close()
|
||
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
|
||
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
|
||
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
|
||
SourcedSongUrl(url, "")
|
||
} else {
|
||
null
|
||
}
|
||
} catch (e: Exception) {
|
||
MiliLog.localWarn(
|
||
"SONG-DB 查询 cloud_url 异常 编号=$musicno ${e.javaClass.simpleName}: ${e.message}",
|
||
)
|
||
null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底)。
|
||
*
|
||
* ⚠️ host 一律用 [AdVideoFilter.hostOf](**已剥离 query/fragment,且 URI 解析失败会退化**):
|
||
* 旧实现直接 `java.net.URI(url).host`,query 里出现 `|`/空格这类字符时 `URI()` 抛异常,
|
||
* 被 `?: return true` 当成 demo —— 合法真源地址会因此被拒("取址失败"的隐蔽来源)。
|
||
*
|
||
* ⚠️ 裸 IP 不再一律判 demo:命中媒体地址允许清单([AdVideoFilter.isAllowedMediaHost])时
|
||
* 放行 —— 直连内网自建源时 DB 里的 `cloud_url` 就是裸 IP。
|
||
*/
|
||
private fun isLikelyDemoUrl(url: String): Boolean {
|
||
val host = AdVideoFilter.hostOf(url) ?: return true
|
||
// 纯 IP 地址 → 高概率 demo(**允许清单里的主机除外**)
|
||
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")) &&
|
||
!AdVideoFilter.isAllowedMediaHost(host)) return true
|
||
// Cloudflare R2 demo bucket (pub-*.r2.dev)
|
||
if (host.matches(Regex("^pub-[a-z0-9]+\\.r2\\.dev$"))) return true
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* 重置歌曲源会话(清 JS 侧 token / API 实例)。
|
||
*
|
||
* 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是
|
||
* 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。
|
||
*/
|
||
@JvmStatic
|
||
fun clearTokenCache() {
|
||
bridge?.resetApiSession()
|
||
}
|
||
|
||
/** 语义更明确的别名:断网恢复后重建歌曲源会话。 */
|
||
@JvmStatic
|
||
fun resetSourceSession() {
|
||
bridge?.resetApiSession()
|
||
}
|
||
|
||
/**
|
||
* 设置页改了「歌曲源 / 媒体地址允许清单」后**立即**把新配置注入 JS 会话(隐患 1)。
|
||
*
|
||
* 原生侧由 [SongUrlResolver.applySourceHosts] 立刻生效;JS 侧过去只在下一次"重建会话"
|
||
* (`window.api == null`)时才拿到新列表,已构造的 `window.api.sourceOrder` 仍是旧顺序 ——
|
||
* 两条取址路径会在同一时刻用**不同的源顺序**。现在保存设置后直接重新注入
|
||
* (`KtvApiConfig.*` + `window.api.sourceOrder`),两端立刻一致。
|
||
*/
|
||
@JvmStatic
|
||
fun applySourceSettings() {
|
||
bridge?.applySourceSettingsNow()
|
||
}
|
||
|
||
/** 最近一次取址命中的源:优先 JS 上报,其次原生兜底记录(供远程日志的「请求源=」)。 */
|
||
@JvmStatic
|
||
fun lastSongSourceHost(): String =
|
||
bridge?.lastSongSourceHost?.takeIf { it.isNotEmpty() } ?: SongUrlResolver.lastHost
|
||
}
|