取址改为官方源优先+自建源兜底,只有官方源成功才上报(V1.6.2/45,未发布)
需求一:流程反转 + 上报绑定官方源成功 - 默认 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。
This commit is contained in:
@@ -63,6 +63,12 @@ object SongApiClient {
|
||||
* (JS 桥 / 原生兜底 / DB 回退 / 主线程 DB 兜底),只有本方法能保证"**只要用户真的拿到了
|
||||
* 一条可信下载地址,就恰好推送一次**";挂在别处必然漏掉某条路径(历史上广告闸门就是这么漏的)。
|
||||
*
|
||||
* ⚠️ **只有"地址来自官方源"才推送**(2026-10-14 新流程):官方源成功 → 把该下载地址
|
||||
* 上报自建服务器(`POST /push_download`,让它留存并入库这首);地址来自**自建源**时不推
|
||||
* (服务器本来就有这首),DB 兜底同样不推。判定用的是取址实现**显式返回**的来源标志
|
||||
* ([SourcedSongUrl.sourceHost],见 [SongSourceFlow.shouldReportToSelfHost]),
|
||||
* **不靠 URL 字符串猜、也不读可变全局**。
|
||||
*
|
||||
* 推送本身是 fire-and-forget(见 [PushDownloadClient]):默认关闭、后台线程、8 秒超时、
|
||||
* 失败只写本地日志,**不阻塞播放/下载,也不改返回值**。
|
||||
*
|
||||
@@ -70,16 +76,25 @@ object SongApiClient {
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean, songName: String): String? {
|
||||
val url = resolveSongDownloadUrl(musicno, resolution, h265)
|
||||
if (!url.isNullOrEmpty() && musicno.isNotBlank()) {
|
||||
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
|
||||
}
|
||||
|
||||
/** 真正的取址实现(推送不参与其逻辑,见上面的入口)。 */
|
||||
private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
|
||||
/**
|
||||
* 真正的取址实现(推送不参与其逻辑,见上面的入口)。
|
||||
*
|
||||
* 返回 [SourcedSongUrl](= 地址 + **产出它的源**)而不是裸字符串:汇合点据此判定
|
||||
* "是否上报自建服务器"(见 [getSongDownloadUrl])。回落链
|
||||
* JS 主路径 → 原生兜底 → DB 回退由 [SongSourceFlow.firstAvailable] 表达。
|
||||
*/
|
||||
private fun resolveSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): SourcedSongUrl? {
|
||||
// 空编号必须提前挡掉(推送的 `tid` 也要求非空),否则会拿空编号去扫一遍上游源
|
||||
if (musicno.isBlank()) return null
|
||||
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
|
||||
@@ -90,11 +105,18 @@ object SongApiClient {
|
||||
Log.w(TAG, "Synchronous request on main thread; using DB fallback only")
|
||||
return fetchFromDb(musicno)
|
||||
}
|
||||
val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
val bridge = bridge
|
||||
?: return SongSourceFlow.firstAvailable(
|
||||
{ resolveNatively(musicno, resolution, h265) },
|
||||
{ fetchFromDb(musicno) },
|
||||
)
|
||||
// 已判定本机 WebView 不可用 → 不再每次白等 15 秒,直接用原生通道。
|
||||
// (极米 H1S 实测:每次都等满 15 秒,远程日志里每首歌前都有一条「15s 未就绪」。)
|
||||
if (bridge.isUnavailable()) {
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
return SongSourceFlow.firstAvailable(
|
||||
{ resolveNatively(musicno, resolution, h265) },
|
||||
{ fetchFromDb(musicno) },
|
||||
)
|
||||
}
|
||||
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
|
||||
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
|
||||
@@ -112,11 +134,18 @@ object SongApiClient {
|
||||
// 并把"不可用"记成持久状态(只上报一次),后续点歌不再等这 15 秒。
|
||||
Log.w(TAG, "Bridge not ready after wait, native resolver")
|
||||
bridge.markUnavailable("15s 未就绪")
|
||||
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
return SongSourceFlow.firstAvailable(
|
||||
{ resolveNatively(musicno, resolution, h265) },
|
||||
{ fetchFromDb(musicno) },
|
||||
)
|
||||
}
|
||||
val result = AtomicReference<String?>()
|
||||
val result = AtomicReference<SourcedSongUrl?>()
|
||||
val latch = CountDownLatch(1)
|
||||
bridge.getSongUrl(musicno, resolution, h265) { url -> result.set(url); latch.countDown() }
|
||||
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)
|
||||
@@ -125,37 +154,60 @@ object SongApiClient {
|
||||
}
|
||||
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
|
||||
// 避免 JS 侧偶发失败直接把第一首歌判死。
|
||||
val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
|
||||
if (url != null) {
|
||||
val resolved = SongSourceFlow.firstAvailable(
|
||||
{ result.get() },
|
||||
{ resolveNatively(musicno, resolution, h265) },
|
||||
{ fetchFromDb(musicno) },
|
||||
)
|
||||
if (resolved != null) {
|
||||
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
|
||||
Eac19WindowGuard.noteUrlOk()
|
||||
}
|
||||
return url
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
|
||||
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
|
||||
*/
|
||||
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? =
|
||||
runCatching { SongUrlResolver.resolve(musicno, resolution, h265) }
|
||||
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 fallback — 必须通过 demo 检测才能使用 */
|
||||
private fun fetchFromDb(musicno: String): String? { try {
|
||||
/**
|
||||
* 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()) {
|
||||
val url = db.getCloudUrl(musicno)
|
||||
db.close()
|
||||
// DB 中的 cloud_url 也可能就是广告/demo 地址,必须过同一道可信校验
|
||||
// (2026-09-26 广告事故:这里原来只做 isLikelyDemoUrl,挡不住广告域名)。
|
||||
if (url != null && !isLikelyDemoUrl(url) && AdVideoFilter.isTrustedSongUrl(url, musicno)) {
|
||||
return url
|
||||
}
|
||||
if (!db.open()) {
|
||||
MiliLog.localWarn("SONG-DB 曲库未就绪,跳过 cloud_url 回落 编号=$musicno")
|
||||
return null
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -195,6 +247,19 @@ object SongApiClient {
|
||||
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 =
|
||||
|
||||
Reference in New Issue
Block a user