取址改为官方源优先+自建源兜底,只有官方源成功才上报(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:
zijunchiang
2026-10-14 03:45:07 +08:00
parent e00a1ace79
commit d01269e917
22 changed files with 1138 additions and 287 deletions
@@ -3,14 +3,17 @@ package com.local.ktv
import android.util.Log
/**
* 屏蔽/替换后端注入的贴片广告视频。
* 屏蔽后端注入的贴片广告视频。
*
* 后端在下发歌曲播放地址时,可能把广告视频(例如
* `http://gz.ac16.vip/ad_files/my_ad_video.ts`)伪装成歌曲地址返回。
* 这里统一识别并处理:
* - 默认「屏蔽」:广告地址被拦下后,调用方会回落到数据库中的真实歌曲地址
* (`SongApiClient` 的 DB fallback),从而直接播放歌曲、跳过广告。
* - 若需要「替换」成自己的视频,把 [replacementUrl] 设置为目标地址即可。
* 这里统一识别并处理:命中广告特征的地址返回 **null(屏蔽)**,
* 调用方据此回落到数据库中的真实歌曲地址或重新取址
* (`SongApiClient` 的 DB fallback / `SongOkDownloadManager` 的最终闸门)。
*
* ⚠️ 2026-10-14 删除了原来那个"替换成自己的视频"的开关 `replacementUrl`:
* 全仓搜索(`grep -rn replacementUrl`)**没有任何写入点**,恒为 null,即"替换"这条分支
* 从来没有生效过,属于死配置。行为与删除前完全一致(命中广告 → 返回 null)。
*/
object AdVideoFilter {
private const val TAG = "AdVideoFilter"
@@ -45,14 +48,6 @@ object AdVideoFilter {
"ac16.vip",
)
/**
* 替换地址。为 null 时表示「屏蔽」(返回 null,由 DB 真实歌曲地址兜底);
* 设置后,命中广告时用该地址替换原广告。
*/
@Volatile
@JvmField
var replacementUrl: String? = null
/**
* **媒体地址允许清单**(设置项 `KtvStore.allowedMediaHosts`,默认
* `md.kasugano.cn` + `192.168.100.232`)。
@@ -203,17 +198,15 @@ object AdVideoFilter {
private val R2_HOST = Regex("^pub-[a-z0-9]+\\.r2\\.dev$", RegexOption.IGNORE_CASE)
/**
* 处理播放地址:广告地址返回 null(屏蔽)或 [replacementUrl](替换);
* 非广告地址原样返回。
* 处理播放地址:广告地址返回 **null(屏蔽)**,非广告地址原样返回。
*
* ⚠️ 原来的「替换」分支(`replacementUrl`)已于 2026-10-14 删除:全仓没有任何写入点,
* 恒为 null,即"命中广告→返回替换地址"从未生效。现在只保留屏蔽语义。
*/
@JvmStatic
fun resolve(url: String?): String? {
if (!isAdVideo(url)) return url
if (replacementUrl.isNullOrEmpty()) {
Log.w(TAG, "已屏蔽广告视频: $url")
return null
}
Log.i(TAG, "已替换广告视频: $url -> $replacementUrl")
return replacementUrl
Log.w(TAG, "已屏蔽广告视频: $url")
return null
}
}
+178 -47
View File
@@ -70,11 +70,130 @@ class KtvJsBridge(context: Context) {
} catch (e) {}
return 'ok';
})()"""
/** 热更新脚本的兼容标记(`ktv_api.js` 两处 + 本类两处校验,改协议必须一起改)。 */
private const val BRIDGE_API_MARKER = "KTV_BRIDGE_API: 15"
/**
* 已知 CDN 域名后缀(**白名单**,仅用于 [isTrustedDownloadUrl] 的最后一步)。
*
* ⚠️ **`ac16.vip` 已从这里移除**(2026-10-14):它是**广告域名**,本来就躺在
* [AdVideoFilter.AD_HOSTS] 黑名单里。旧实现把它同时放进白名单,只因为 `complete()`
* 先调了 `AdVideoFilter.resolve()` 才没出事 —— 那是一个**依赖调用顺序的安全网**。
* 现在 [isTrustedDownloadUrl] 自己**先判黑名单**,顺序无关。
*/
private val TRUSTED_HOST_SUFFIXES = listOf(
"ktvsky.com", "origjoy.com", "cherryonline.cn",
"ktvdaren.com", "j-make.cn",
)
/** 裸 IPv4 主机(App 只接受域名形式的媒体地址;**命中允许清单的除外**)。 */
private val IP_HOST_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")
/** Cloudflare R2 公开测试桶(配合 path 里的 `/ss/` 判定)。 */
private val R2_HOST_PATTERN = Regex("^pub-[a-z0-9]+\\.r2\\.dev$")
/** body 里的**业务码 403**(源站风控的真实形态:HTTP 200 + `{"code":403,…}`)。 */
private val BODY_CODE_403 = Regex("\"code\"\\s*:\\s*403(?!\\d)")
/**
* JS 返回地址的可信性判定(**纯函数**,不写日志、不碰 Android API → 可跑纯 JVM 单测)。
*
* 判定顺序(**黑名单优先**,与 [AdVideoFilter.isTrustedSongUrl] 同口径):
* ```
* ① 非 http(s) → 拒
* ② AdVideoFilter.isAdVideo(wb66/demo/ac16.vip/ad_files/my_ad_video)→ 拒(最高优先级)
* ③ pub-*.r2.dev 且 path 含 /ss/ → 拒
* ④ 命中媒体地址允许清单(自建源裸 IP) → 放行
* ⑤ 裸 IP → 拒
* ⑥ 已知 CDN 后缀白名单 → 放行,否则拒
* ```
* 顺序无关:即使调用方忘了先跑 [AdVideoFilter.resolve],广告域名也不会被放行。
*/
@JvmStatic
fun isTrustedDownloadUrl(url: String?): Boolean {
if (url == null) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
val host = AdVideoFilter.hostOf(url) ?: return false
// ② 广告黑名单:优先级最高,命中允许清单也拒(顺序无关)。
if (AdVideoFilter.isAdVideo(url)) return false
// ③ 测试资源主机(Cloudflare 公开测试桶)。
if (R2_HOST_PATTERN.matches(host) && AdVideoFilter.pathOf(url).contains("/ss/")) return false
// ④ 媒体地址允许清单(自建源:md.kasugano.cn / 内网裸 IP)。
if (AdVideoFilter.isAllowedMediaHost(host)) return true
// ⑤ 裸 IP:App 只接受域名(允许清单在上面已放行)。
if (IP_HOST_PATTERN.matches(host)) return false
// ⑥ "必须是已知 CDN 域名"。
return TRUSTED_HOST_SUFFIXES.any { host.endsWith(it) }
}
/**
* 风控判定(HTTP 状态 + **业务码**):源站风控有两种形态 ——
* ① HTTP 403;② HTTP 200 + body `{"code":403,"msg":"请求被拒绝:检测到异常行为"}`。
*
* JS 路径过去只认前者(`status == 403`),**业务码 403 的风控信号在 JS 路径上会丢**;
* 现在两种都认,与原生 [SongUrlResolver.sawForbidden] 的口径一致。
*
* ⚠️ 手写判定(不用 `org.json`):单测跑在纯 JVM 上,Android 的 `org.json` 是空壳。
* 正则带 `(?!\\d)` 负向断言,避免把 `4031` 之类误判成 403。
*/
@JvmStatic
fun riskForbidden(status: Int, body: String?): Boolean {
if (status == 403) return true
if (body.isNullOrBlank()) return false
return BODY_CODE_403.containsMatchIn(body)
}
/**
* 「设置页改歌曲源/允许清单后**立即生效**」的注入脚本(纯字符串构造,可单测)。
*
* 为什么需要它:`getSongUrl` 里的配置注入写在 `if (!window.api)` 内,只在**重建会话**时
* 执行;而 `window.api.sourceOrder` 是构造时对 `CONFIG.HOSTS` 的快照。
* 保存设置后如果只改 Kotlin 侧,已建好的 `window.api` 仍按旧顺序选源 ——
* 原生与 JS 会在同一时刻用**不同的源顺序**。这里同时:
* ① 覆写 `KtvApiConfig.HOSTS / HOST / ALLOWED_MEDIA_HOSTS`(下次构造实例用);
* ② 把**已构造实例**的 `sourceOrder` 重置为新的源列表(当前正在用的顺序)。
*/
@JvmStatic
fun buildApplySourceSettingsJs(hosts: List<String>, allowedMediaHosts: List<String>): String = """
(function() {
var hosts = ${jsArray(hosts)};
var allowed = ${jsArray(allowedMediaHosts)};
try {
if (window.KtvApiConfig) {
window.KtvApiConfig.HOSTS = hosts;
window.KtvApiConfig.HOST = hosts.length ? hosts[0] : '';
window.KtvApiConfig.ALLOWED_MEDIA_HOSTS = allowed;
}
} catch (e) {}
try {
if (window.api) {
window.api.sourceOrder = hosts.slice();
}
} catch (e) {}
return 'ok';
})()
""".trimIndent()
/** 手写 JSON 数组 / 字符串(`org.json` 在单测里是空壳,见 [DeviceIdentity] 类头注释)。 */
private fun jsArray(items: List<String>): String =
items.joinToString(",", "[", "]") { jsQuote(it) }
private fun jsQuote(text: String): String =
"\"" + text.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
}
private val appContext = context.applicationContext
private val mainHandler = Handler(Looper.getMainLooper())
private val callbacks = ConcurrentHashMap<String, (String?) -> Unit>()
/**
* 取址回调:`(地址, 产出该地址的源)`。第二项是**显式的来源标志**(由 JS 在返回前
* 通过 `android.onSongSource(requestId, host)` 送达),用于汇合点判定"是否上报自建服务器"。
*/
private val callbacks = ConcurrentHashMap<String, (String?, String?) -> Unit>()
/** 每个取址请求命中的源(`requestId -> host`),由 `onSongSource` 写入、`complete` 取出。 */
private val pendingSourceHosts = ConcurrentHashMap<String, String>()
@Volatile private var webView: WebView? = null
@Volatile private var ready = false
@@ -287,15 +406,15 @@ class KtvJsBridge(context: Context) {
musicNo: String,
resolution: String,
h265: Boolean,
callback: (String?) -> Unit,
callback: (String?, String?) -> Unit,
) {
if (!ready || destroyed) {
callback(null)
callback(null, null)
return
}
val requestId = UUID.randomUUID().toString()
callbacks[requestId] = callback
// 源列表 / 媒体地址允许清单**以 App 设置项为准**(自建源优先、官方源兜底):
// 源列表 / 媒体地址允许清单**以 App 设置项为准**(官方源优先、自建源兜底):
// 沿用既有"配置注入"机制(覆写 window.KtvApiConfig),JS 无需重新发版即可换源。
val hosts = sourceHostsForJs()
val allowedHosts = allowedMediaHostsForJs()
@@ -303,10 +422,12 @@ class KtvJsBridge(context: Context) {
(function() {
var requestId = ${JSONObject.quote(requestId)};
var completed = false;
function finish(url) {
function finish(url, host) {
if (completed) return;
completed = true;
clearTimeout(timer);
// 来源标志:显式回传本次命中的源(老 APK 上没有这个方法 → 忽略,退回 onUrl 的旧形态)。
try { android.onSongSource(requestId, host || ''); } catch (e) {}
android.onUrl(requestId, url || '');
}
var timer = setTimeout(function() { finish(''); }, $JS_REQUEST_TIMEOUT_MS);
@@ -340,10 +461,14 @@ class KtvJsBridge(context: Context) {
});
}
await window._apiInitPromise;
return window.api.getSongUrl(
var url = await window.api.getSongUrl(
${JSONObject.quote(musicNo)}, ${JSONObject.quote(resolution)}, ${if (h265) "true" else "false"}
);
}).then(finish).catch(function(error) {
// 本次命中的源(KtvApi 成功时记录;失败为空)——随地址一起回传,供"是否上报"判定。
return {url: url, host: window.api.lastSourceHost || ''};
}).then(function(result) {
finish(result && result.url, result && result.host);
}).catch(function(error) {
android.log('getSongUrl failed: ' + (error && error.message ? error.message : error));
finish('');
});
@@ -355,42 +480,30 @@ class KtvJsBridge(context: Context) {
}
}
// JS isDemoUrl 已做第一层正则过滤; 这里再做域名白名单兜底, 防止 demo URL 绕过
private val trustedHostSuffixes = listOf(
"ktvsky.com", "origjoy.com", "cherryonline.cn", "ac16.vip",
"ktvdaren.com", "j-make.cn",
)
// 已知 demo 域名模式 (Cloudflare R2 测试桶、IP 地址等)
private val ipHostPattern = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")
private val r2HostPattern = Regex("^pub-[a-z0-9]+\\.r2\\.dev$")
private val demoHostPatterns = listOf(ipHostPattern, r2HostPattern)
private fun isTrustedDownloadUrl(url: String?): Boolean {
if (url == null) return false
if (!url.startsWith("http://") && !url.startsWith("https://")) return false
val host = AdVideoFilter.hostOf(url) ?: return false
// R2 公开测试桶(`pub-*.r2.dev/ss/`)是**黑名单**,优先级最高:命中允许清单也要拒
// (与 AdVideoFilter.isTrustedSongUrl 的判定顺序保持一致)。
if (r2HostPattern.matches(host) && AdVideoFilter.pathOf(url).contains("/ss/")) {
Log.w(TAG, "Rejected demo URL host: $host")
return false
/**
* 「设置页改歌曲源/允许清单」后**立即**把新配置注入 JS 会话(原生侧同一时刻已由
* [SongUrlResolver.applySourceHosts] 生效)。见 [buildApplySourceSettingsJs]。
*/
fun applySourceSettingsNow() {
val view = webView
if (!ready || destroyed || view == null) return
val script = buildApplySourceSettingsJs(sourceHostsForJs(), allowedMediaHostsForJs())
mainHandler.post {
runCatching { view.evaluateJavascript(script, null) }
.onFailure { Log.w(TAG, "apply source settings failed", it) }
}
// 媒体地址允许清单(自建源:md.kasugano.cn / 内网裸 IP):放行"必须是已知 CDN 域名"
// 与"必须是域名"这两条限制。其余广告黑名单(wb66/demo/ac16.vip/ad_files/my_ad_video)
// 已由调用点的 AdVideoFilter.resolve()(isAdVideo)先拦过一遍,**优先级更高**。
if (AdVideoFilter.isAllowedMediaHost(host)) return true
if (demoHostPatterns.any { it.matches(host) }) {
Log.w(TAG, "Rejected demo URL host: $host")
return false
}
val trusted = trustedHostSuffixes.any { host.endsWith(it) }
if (!trusted) Log.w(TAG, "Untrusted URL host: $host")
return trusted
}
private fun complete(requestId: String, url: String?) {
// 先屏蔽/替换广告视频,再做域名白名单校验。
callbacks.remove(requestId)?.invoke(AdVideoFilter.resolve(url)?.takeIf { isTrustedDownloadUrl(it) })
// 本次地址来自哪个源:**显式标志**优先,取不到才退回"最近一次 JS 上报的源"。
val sourceHost = pendingSourceHosts.remove(requestId).orEmpty().ifEmpty { lastSongSourceHost }
// 先屏蔽广告视频(AdVideoFilter 黑名单),再过 CDN 白名单/允许清单(本类自带黑名单优先判定)。
val resolved = AdVideoFilter.resolve(url)
val trusted = resolved?.takeIf { isTrustedDownloadUrl(it) }
if (resolved != null && trusted == null) {
Log.w(TAG, "Untrusted URL rejected: host=${AdVideoFilter.hostOf(resolved)}")
}
callbacks.remove(requestId)?.invoke(trusted, sourceHost)
}
private fun readAssetScript(): String =
@@ -400,7 +513,7 @@ class KtvJsBridge(context: Context) {
runCatching {
file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let {
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 14")
it.contains("KtvApi") && it.contains(BRIDGE_API_MARKER)
}
}.getOrDefault(false)
@@ -426,7 +539,7 @@ class KtvJsBridge(context: Context) {
val script = String(bytes, StandardCharsets.UTF_8)
require(
script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 14"),
script.contains(BRIDGE_API_MARKER),
) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) {
@@ -503,6 +616,18 @@ class KtvJsBridge(context: Context) {
complete(requestId, url)
}
/**
* JS 在返回地址**之前**回传"本次命中的源"(`requestId -> host`)。
*
* 这是"是否上报自建服务器"的**显式来源标志**(只有官方源成功才上报),
* 与 `onUrl` 分开传是为了兼容旧脚本:老脚本不会调它,
* [complete] 会退回 `lastSongSourceHost`(由 `reportSongResult` 维护)。
*/
@JavascriptInterface
fun onSongSource(requestId: String, host: String) {
if (host.isNotEmpty()) pendingSourceHosts[requestId] = host
}
@JavascriptInterface
fun rsaEncryptPkcs1(publicKeyBase64: String, plaintext: String): String =
runCatching {
@@ -562,17 +687,23 @@ class KtvJsBridge(context: Context) {
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
// 补发第二次(限流时补发只会双倍放大请求量,加速被源限流)。
val raw = rawRequest(method, url, body, headersJson, timeoutMs)
val status = runCatching { JSONObject(raw).optInt("status") }.getOrDefault(0)
val envelope = runCatching { JSONObject(raw) }.getOrNull()
val status = envelope?.optInt("status") ?: 0
if (status > 0) {
val responseBody = envelope?.optString("body").orEmpty()
// 远程日志: 风控/服务端错误(403/5xx)实时上报, 便于排查间歇性点歌失败。
if (status == 403 || status >= 500) {
MiliLog.localError("HTTP-$status $method $url")
}
// 歌曲源试用窗口/风控到期: 对陌生出口 IP 可能返回 403"检测到异常行为",
// 换公网 IP 恢复。通知 UI 弹一次提示引导换 IP。
// ⚠️ 取 token 的 `/i.php` 同样会 403(2026-10 实测主源 w.w345.my/i.php 就是 403)——
// 以前只认 `/music/do.php`,全都 403 时用户看不到任何提示。
if (status == 403 && (url.contains("/i.php") || url.contains("/music/do.php"))) {
// 歌曲源试用窗口/风控到期有两种形态:
// ① HTTP 403;② **HTTP 200 + 业务码 403**(`{"code":403,"msg":"请求被拒绝:检测到异常行为"}`,
// 2026-10-08 实测)。旧实现只认 ①,JS 路径上的风控信号就丢了
// (远程日志里只能看到 `do.php code=403`);现在两种都认,
// 与原生 `SongUrlResolver.sawForbidden` 口径一致。
// ⚠️ 取 token 的 `/i.php` 同样会 403 —— 以前只认 `/music/do.php`。
if (riskForbidden(status, responseBody) &&
(url.contains("/i.php") || url.contains("/music/do.php"))
) {
Eac19WindowGuard.noteRiskBlocked()
}
return raw
+16 -4
View File
@@ -68,21 +68,27 @@ class KtvStore {
* 「App 点歌推送」:把点歌取到的下载地址同步推给自建服务器(`POST <地址>/push_download`,
* 见 [PushDownload] / [PushDownloadClient] / [SongApiClient.getSongDownloadUrl])。
*
* * **留空 = 自动**:若 [sourceHosts] 的第一项是自建源,就用它当推送目标
* (见 [PushDownload.effectiveServerUrl],2026-10-14 自建源接入后成为默认行为);
* * **留空 = 自动**:从 [sourceHosts] 里找**第一个自建源**用它当推送目标
* (见 [PushDownload.effectiveServerUrl];2026-10-14 起源列表是"官方源优先"、自建源不再排第一,
* 所以不再看 `first`);
* * **填 `off` / `none` / `关闭` = 显式关闭**(连请求都不发);
* * 填其它 `http(s)://…` = 显式覆盖(现有用户完全不受影响)。
*
* ⚠️ 只有"地址来自官方源"的取址成功才会真的推送(自建源自己给出的地址不上报)。
*/
@JvmField var pushServerUrl = ""
/** 推送鉴权串:必须与服务端的 `push_token` 一致(默认 [DEFAULT_PUSH_TOKEN])。 */
@JvmField var pushToken = DEFAULT_PUSH_TOKEN
/**
* **歌曲源入口列表**(自建源优先、官方源兜底,默认 = [SourceHosts.DEFAULT])。
* **歌曲源入口列表**(官方源优先、自建源兜底,默认 = [SourceHosts.DEFAULT])。
*
* App 侧([SongUrlResolver])与 JS 侧(`KtvJsBridge` 注入 `CONFIG.HOSTS`)**都以它为准**;
* 设置页按"一行一个"编辑,解析/归一化见 [SourceHosts.parse] / [SourceHosts.parseList]
* —— 清空即回落默认列表,绝不会变成"没有源可用"。
*
* ⚠️ 1.6.1(已打包、未发布)落盘的"自建源优先"旧默认值会在 [load] 里迁移成新默认
* (见 [SourceHosts.migrateLegacyDefault]);用户自定义的列表一律不动。
*/
@JvmField val sourceHosts: MutableList<String> = SourceHosts.DEFAULT.toMutableList()
@@ -153,8 +159,14 @@ class KtvStore {
pushServerUrl = root.optString("pushServerUrl", "")
pushToken = root.optString("pushToken", DEFAULT_PUSH_TOKEN).ifBlank { DEFAULT_PUSH_TOKEN }
// 源列表 / 媒体地址允许清单:空(旧配置没有这两个键)→ 回落默认值。
// ⚠️ 源列表额外做一次"旧默认值 → 新默认值"迁移(1.6.1 落盘的是自建源优先;
// 用户自定义的列表不受影响),见 SourceHosts.migrateLegacyDefault。
sourceHosts.clear()
sourceHosts.addAll(SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts"))))
sourceHosts.addAll(
SourceHosts.migrateLegacyDefault(
SourceHosts.parseList(readStringList(root.optJSONArray("sourceHosts"))),
),
)
allowedMediaHosts.clear()
allowedMediaHosts.addAll(SourceHosts.parseAllowedList(readStringList(root.optJSONArray("allowedMediaHosts"))))
readSongs(root.optJSONArray("orderQueue"), orderQueue)
+44 -112
View File
@@ -140,9 +140,10 @@ class MainActivity : AppCompatActivity() {
/**
* 「切歌 → 公放」专用单线程执行器。
*
* ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`streamDownloadAndPlay`)这类
* 几十秒的长任务也在里面。极米 H1S 上并发点几首歌就能把池占满,切歌任务于是一直排队 ——
* 旧歌播完后彻底没声、按什么都没反应(用户报的"老设备卡死",新设备因下载快而看不出来)。
* ⚠️ **绝不能用 [io] 池**:它只有 4 个线程,"点歌即下即播"(`startDownloadAndPlay` →
* `downloadSong` → `SongOkDownloadManager`)这类几十秒的长任务也在里面。极米 H1S 上并发点
* 几首歌就能把池占满,切歌任务于是一直排队 —— 旧歌播完后彻底没声、按什么都没反应
* (用户报的"老设备卡死",新设备因下载快而看不出来)。
*/
private val randomPlayExecutor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "ktv-random-play").apply { isDaemon = true }
@@ -482,7 +483,7 @@ class MainActivity : AppCompatActivity() {
Log.w(TAG, "存储目录创建失败: ${AppPaths.storageFacts(this)}")
}
store.load()
// 歌曲源列表 / 媒体地址允许清单(自建源优先、官方源兜底):一处装载,
// 歌曲源列表 / 媒体地址允许清单(**官方源优先、自建源兜底**):一处装载,
// 原生取址(SongUrlResolver)、地址闸门(AdVideoFilter)与 JS 取址(KtvJsBridge 注入
// CONFIG.HOSTS / CONFIG.ALLOWED_MEDIA_HOSTS)三处都读它。
applySourceSettings()
@@ -4981,6 +4982,14 @@ class MainActivity : AppCompatActivity() {
if (currentPage == "已点" || currentPage == "首页") renderQueue()
}
/**
* 备用/历史下载入口(**第五条地址消费点**):直接用曲库条目自带的 `downloadUrl`/`videoUrl`。
*
* ⚠️ 它**不取址**,所以地址必须自己过闸门([AdVideoFilter.isTrustedSongUrl],与四条取址路径
* 同一口径)。历史实现直接下载、完全不过闸门 —— 一旦曲库条目(`Song.remote()` 会填
* `downloadUrl`)里带的是广告地址,就会绕过全部防线。不可信一律按「源失效」处理:
* 记日志 + 走 [Eac19WindowGuard.noteAllSourcesFailed](「远程源已全部失效,等待新版本修复」)。
*/
private fun download(song: Song) {
val mainUrl = if (song.downloadUrl == null || song.downloadUrl!!.trim { it <= ' ' }
.isEmpty()) song.videoUrl else song.downloadUrl
@@ -4988,6 +4997,15 @@ class MainActivity : AppCompatActivity() {
toast("该条目没有下载地址")
return
}
// 地址闸门:不可信(广告/错号/非 http(s)/裸 IP…)按"源失效"处理,绝不静默播放。
if (!AdVideoFilter.isTrustedSongUrl(mainUrl, extractTid(song))) {
MiliLog.error(
"点歌编号=${extractTid(song)} 标题=${song.title} 状态=失败 " +
"详情=地址不可信(备用下载入口) url=$mainUrl",
)
Eac19WindowGuard.noteAllSourcesFailed()
return
}
val task = DownloadTask(song)
downloads.add(task)
busy(true, "正在下载:" + song.title)
@@ -5225,102 +5243,6 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* 完整下载后播放。VideoView不支持流式TS,必须下载完再播。
* 使用256KB大缓冲区加速下载,实时显示进度。
*/
private fun streamDownloadAndPlay(song: Song) {
io.execute(Runnable {
var tmpFile: File? = null
try {
var url = song.downloadUrl
if (url == null || url.isEmpty()) {
// 4 参重载 = 顺带把歌名交给「App 点歌推送」(推送挂在取址汇合点里,见 SongApiClient)
url = getSongDownloadUrl(extractTid(song).orEmpty(), "720", false, song.title.orEmpty())
if (url != null) song.downloadUrl = url
}
if (url == null || url.isEmpty()) {
MiliLog.error(
"点歌编号=${song?.let(::extractTid)} 标题=${song?.title} 请求源=" +
"${SongUrlResolver.lastHost.ifEmpty { "unknown" }} 状态=失败 详情=无法获取播放地址",
)
main.post(Runnable {
hideDownloadProgress()
toast("无法获取播放地址")
})
return@Runnable
}
val finalFile = getLocalFile(song)
finalFile.getParentFile().mkdirs()
tmpFile = File(finalFile.getParent(), finalFile.getName() + ".tmp")
val conn = URL(url).openConnection() as HttpURLConnection
conn.setConnectTimeout(10000)
conn.setReadTimeout(30000)
conn.setRequestProperty("User-Agent", "ThunderSDK/4.1.3")
conn.connect()
val totalSize = conn.getContentLength().toLong()
val `in`: InputStream = BufferedInputStream(conn.getInputStream(), 262144)
val out = FileOutputStream(tmpFile)
val buf = ByteArray(262144) // 256KB buffer for speed
var downloaded: kotlin.Long = 0
var lastPct = 0
var lastTime = System.currentTimeMillis()
var lastBytes: kotlin.Long = 0
while (true) {
val read = `in`.read(buf)
if (read < 0) break
out.write(buf, 0, read)
downloaded += read.toLong()
// 每秒更新一次进度和速度
val now = System.currentTimeMillis()
if (now - lastTime > 1000) {
val pct = if (totalSize > 0) (downloaded * 100 / totalSize).toInt() else 0
val speed = downloaded - lastBytes
lastPct = pct
lastTime = now
lastBytes = downloaded
val p = pct
val speedStr = formatSize(speed) + "/s"
main.post(Runnable {
updateDownloadProgress(song, p)
if (textDownloadSpeed != null) textDownloadSpeed!!.setText(speedStr)
})
}
}
out.close()
`in`.close()
conn.disconnect()
// 移到正式位置
tmpFile.renameTo(finalFile)
song.path = finalFile.getAbsolutePath()
randomCandidates[stableId(song)] = song
main.post(Runnable {
hideDownloadProgress()
if (currentSong != null && currentSong!!.equals(song)) {
playLocalFile(song)
}
toast(song.title + " 就绪")
})
} catch (e: Exception) {
Log.e(TAG, "download error: " + e.message)
if (tmpFile != null) tmpFile.delete()
MiliLog.error("DL-ERR id=${song?.let(::extractTid)} title=${song?.title} ${e.message}")
main.post(Runnable {
hideDownloadProgress()
toast("下载失败: " + e.message)
})
}
})
}
/**
* 下载歌曲并在完成后自动播放 (旧版,备用)。
*/
@@ -8869,7 +8791,7 @@ class MainActivity : AppCompatActivity() {
"推送下载",
PushDownload.endpoint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts))
?.let { "已开启:点歌地址推送至 $it" }
?: "未开启(留空 = 自动用首个自建源;填 off 关闭)",
?: "未开启(留空 = 自动用歌曲源里的自建源;填 off 关闭)",
"设置",
) { showPushDownloadSettings() },
SettingsEntry(
@@ -9371,9 +9293,12 @@ class MainActivity : AppCompatActivity() {
/**
* 「推送下载」设置:把点歌取到的下载地址同步推给自建服务器(服务端 `POST /push_download`)。
*
* ⚠️ **留空 = 自动**:首个源是自建源时直接用它当推送目标(见 [PushDownload.effectiveServerUrl]);
* 填 `off` / `none` / `关闭` 则关闭该功能(点歌时连请求都不发);填其它地址 = 显式覆盖。
* ⚠️ **留空 = 自动**:从歌曲源列表里找**自建源**用它当推送目标(见 [PushDownload.effectiveServerUrl];
* 2026-10-14 起源列表是"官方源优先",自建源不再排第一);填 `off` / `none` / `关闭` 则关闭该功能
* (点歌时连请求都不发);填其它地址 = 显式覆盖。
* 改完立刻生效(推送时现读 [store]),不需要重启。
*
* ⚠️ 只有"地址来自官方源"的取址成功才会真的推送(自建源取到的地址不上报)。
*/
private fun showPushDownloadSettings() {
val box = LinearLayout(this)
@@ -9383,12 +9308,12 @@ class MainActivity : AppCompatActivity() {
urlInput.setSingleLine(true)
urlInput.setText(store.pushServerUrl)
urlInput.setHint(PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
.ifEmpty { SourceHosts.DEFAULT.first() } + "(留空 = 自动)")
.ifEmpty { "歌曲源里没有自建源" } + "(留空 = 自动)")
val tokenInput = EditText(this)
tokenInput.setSingleLine(true)
tokenInput.setText(store.pushToken)
tokenInput.setHint(KtvStore.DEFAULT_PUSH_TOKEN)
box.addView(label("自建服务器地址(留空 = 自动用首个自建源;填 off 关闭)", 14, Color.WHITE))
box.addView(label("自建服务器地址(留空 = 自动用歌曲源里的自建源;填 off 关闭)", 14, Color.WHITE))
box.addView(urlInput, LinearLayout.LayoutParams(-1, dp(58)))
box.addView(label("鉴权串(需与服务端 push_token 一致,默认 local)", 14, Color.WHITE))
box.addView(tokenInput, LinearLayout.LayoutParams(-1, dp(58)))
@@ -9410,19 +9335,26 @@ class MainActivity : AppCompatActivity() {
/**
* 把「歌曲源 / 媒体地址允许清单」设置应用到两份取址实现与地址闸门。
*
* 一处装载、三处生效:原生取址([SongUrlResolver.applySourceHosts])、地址闸门
* ([AdVideoFilter.setAllowedMediaHosts])、JS 取址([KtvJsBridge] 每次取址读同一份设置注入
* `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`)。启动与设置页保存后各调一次即可。
* **四处生效**:原生取址([SongUrlResolver.applySourceHosts])、地址闸门
* ([AdVideoFilter.setAllowedMediaHosts])、JS 取址的下一次构造
* ([KtvJsBridge] 每次取址读同一份设置注入 `CONFIG.HOSTS` / `CONFIG.ALLOWED_MEDIA_HOSTS`),
* 以及**当前已构造的 JS 会话**([SongApiClient.applySourceSettings] → 重新注入
* `KtvApiConfig.*` 并重置 `window.api.sourceOrder`)。
*
* ⚠️ 最后一项是隐患 1 的修复:只写下面前两行时,原生侧**立刻**换源,
* 而 JS 主路径仍按构造时的旧 `sourceOrder` 选源 —— 两条路径会在同一时刻用不同的源顺序。
*/
private fun applySourceSettings() {
SongUrlResolver.applySourceHosts(store.sourceHosts)
AdVideoFilter.setAllowedMediaHosts(store.allowedMediaHosts)
SongApiClient.applySourceSettings()
}
/**
* 「歌曲源」设置:**自建源优先、官方源兜底**,一行一个。
* 「歌曲源」设置:**官方源优先、自建源兜底**,一行一个。
*
* 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`)。
* 原生与 JS 两条取址路径都读这份设置(JS 由 KtvJsBridge 注入 `CONFIG.HOSTS` / `CONFIG.HOST`),
* 保存后**立即**对两条路径生效(见 [applySourceSettings])。
*/
private fun showSourceHostsDialog() {
val input = EditText(this).apply {
@@ -9441,9 +9373,9 @@ class MainActivity : AppCompatActivity() {
label("只认 http(s):// 开头;留空 = 回落默认:\n" + SourceHosts.DEFAULT.joinToString("\n"), 13, Color.LTGRAY),
)
AlertDialog.Builder(this)
.setTitle("歌曲源(自建源优先、官方源兜底)")
.setTitle("歌曲源(官方源优先、自建源兜底)")
.setView(box)
.setMessage("第一个源同时是「推送下载」的默认目标。清空即恢复默认列表。")
.setMessage("官方源成功后会把地址上报自建源(POST /push_download);自建源自己取到的地址不上报。清空即恢复默认列表。")
.setNegativeButton("取消", null)
.setPositiveButton("保存") { _: DialogInterface?, _: Int ->
store.applySourceHostsText(input.text.toString())
@@ -16,9 +16,12 @@ import java.util.LinkedHashMap
* 端点拼接 / body 拼接 / 5 分钟去重 / "配置为空就绝不调用发送器" —— 全是纯函数;
* 真正发请求(后台线程 + 8 秒超时 + HttpURLConnection)在 [PushDownloadClient]。
*
* ⚠️ **默认目标**(2026-10-14 自建源接入后):`pushServerUrl` **留空 = 自动** —— 首源是自建源时
* 就用它当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。
* ⚠️ **默认目标**(2026-10-14):`pushServerUrl` **留空 = 自动** —— 从歌曲源列表里找**自建源**
* 当推送目标(见 [effectiveServerUrl]);显式填 `off`/`none`/`关闭` 才关闭;填其它地址则覆盖。
* 关闭时 [endpoint] 返回 null,调用方连线程都不起、**绝不发请求**。
*
* ⚠️ 触发条件(2026-10-14 起):只有"地址来自**官方源**"的取址成功才推送
* (见 [SourcedSongUrl.fromOfficialSource] / `SongApiClient.getSongDownloadUrl`)。
*/
object PushDownload {
/** 与服务端 `push_token` 的默认值保持一致。 */
@@ -36,7 +39,7 @@ object PushDownload {
/**
* 显式"关闭推送"的取值(见 [effectiveServerUrl])。
*
* 为什么需要它:`pushServerUrl` **留空 = 自动**(首源是自建源时就用它当推送目标),
* 为什么需要它:`pushServerUrl` **留空 = 自动**(从歌曲源列表里找自建源当推送目标),
* 所以"清空关闭"必须有一个明确写法,否则用户没法把自动默认关掉。
*/
private val DISABLED_VALUES = setOf("off", "none", "no", "0", "false", "关闭", "禁用")
@@ -49,8 +52,12 @@ object PushDownload {
*
* 1. 显式配置优先:非空且不是 [DISABLED_VALUES] → 原样使用(**现有用户完全不受影响**);
* 2. 显式关闭(`off`/`none`/`关闭`…)→ 返回空串 = 关闭;
* 3. 留空 → 若 `sourceHosts` 的**第一项是自建源**([SourceHosts.isSelfHost])就用它,
* 否则仍关闭(官方源不能当推送目标)。
* 3. 留空 → 取 `sourceHosts` 里**第一个自建源**([SourceHosts.isSelfHost])就用它,
* 一个自建源都没有 → 关闭(官方源不能当推送目标)。
*
* ⚠️ 2026-10-14 起源列表改为「官方源优先、自建源兜底」,自建源**不一定排在第一位** ——
* 所以这里改为"**按列表找第一个自建源**"(旧写法只看 `first`,新默认顺序下会直接把推送关掉,
* 而"官方源成功就上报"正是这一版的核心需求)。
*
* @return 空串 = 关闭;调用方再交给 [endpoint] 判合法性(非 http(s) 一律按关闭处理)。
*/
@@ -60,8 +67,8 @@ object PushDownload {
if (raw.isNotEmpty()) {
return if (raw.lowercase() in DISABLED_VALUES) "" else raw
}
val first = sourceHosts?.firstOrNull()?.trim().orEmpty()
return if (SourceHosts.isSelfHost(first)) first else ""
val self = sourceHosts?.firstOrNull { SourceHosts.isSelfHost(it) }?.trim().orEmpty()
return if (SourceHosts.isSelfHost(self)) self else ""
}
/**
@@ -6,11 +6,12 @@ import java.net.URL
/**
* 「App 点歌推送」的 **Android 侧胶水**:读设置 → 后台线程 → 8 秒超时 → fire-and-forget。
*
* ## 切入点
* ## 切入点与触发条件
* 由 [SongApiClient.getSongDownloadUrl] 在**拿到下载地址之后**调用(那是 JS / 原生 / DB 回退
* 四条取址路径唯一的汇合点)。本类的每一步都**不能影响点歌**:
* * 目标默认:`pushServerUrl` 留空时用**首个自建源**(见 [PushDownload.effectiveServerUrl]);
* 显式 `off`/`none`/`关闭` 或首源不是自建源时 [PushDownload.endpoint] 为 null
* 四条取址路径唯一的汇合点),并且**只有地址来自官方源时才调用**
* (见 [SongSourceFlow.shouldReportToSelfHost])。本类的每一步都**不能影响点歌**:
* * 目标默认:`pushServerUrl` 留空时用歌曲源列表里的**自建源**(见 [PushDownload.effectiveServerUrl]);
* 显式 `off`/`none`/`关闭` 或列表里没有自建源时 [PushDownload.endpoint] 为 null
* → **连线程都不起、绝不发请求**;
* * 5 分钟内存去重([PushDownload.shouldSkip]):同一首歌反复点/重复取址只推一次;
* * 后台守护线程 + 8 秒超时,**失败只写本地日志**([MiliLog.localWarn],不上报远程、不弹提示);
@@ -47,7 +48,7 @@ object PushDownloadClient {
private fun pushNow(tid: Long, url: String, name: String) {
val store = KtvStore()
store.load()
// 目标:显式配置优先;留空且首源是自建源 → 默认用它(2026-10-14 起的行为)。
// 目标:显式配置优先;留空 → 用歌曲源列表里的自建源(2026-10-14 起的行为)。
val serverUrl = PushDownload.effectiveServerUrl(store.pushServerUrl, store.sourceHosts)
if (PushDownload.endpoint(serverUrl) == null) return // 关闭:不发请求(连 POST 都不构造)
val host = SourceHosts.hostOf(serverUrl)
@@ -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 =
@@ -0,0 +1,79 @@
package com.local.ktv
/**
* 「官方源优先、自建源兜底」流程的**纯逻辑**(不碰 Android API,可跑纯 JVM 单测)。
*
* ## 这一版改了什么
* 取址顺序**反转**为「① 官方源 → ② 自建源兜底」:
* ```
* 点歌 → ① 官方源 zk.ktv1.top:6677 取址(/i.php → /music/do.php,多源按需重试)
* ├─ 成功 → 用官方地址播放/下载,并**把该下载地址上报自建源**(POST /push_download)
* └─ 失败 → ② 自建源取址(GET /token → /music/do.php)
* ├─ 有且过地址闸门 → 用自建地址,**不上报**
* └─ 无/失败 → ③ 弹「远程源已全部失效,等待新版本修复」(Eac19WindowGuard)
* ```
*
* ## 为什么把"来源"做成显式的数据而不是环境状态
* "是否上报"取决于**这次地址是哪条路/哪个源给的**。历史写法靠 `lastHost` 之类的可变全局、
* 甚至靠 URL 字符串猜,在多线程下载下会串味、也读不出"到底该不该推"。
* 现在由各取址实现**显式返回** [SourcedSongUrl],在汇合点用
* [shouldReportToSelfHost] 一处判定(见 `SongApiClient.getSongDownloadUrl`)。
*/
data class SourcedSongUrl(
/** 本次取到的媒体地址(已过各自那一道地址闸门)。 */
val url: String,
/**
* 产出该地址的**源**:JS/原生取址时是命中的源入口(如 `http://zk.ktv1.top:6677`),
* DB 回落时是空串(既不是官方源也不是自建源 → 不上报)。
*/
val sourceHost: String,
) {
/**
* 本次地址是否来自**官方源** —— 只有它为 true 才触发「上报自建服务器」。
* 判定收敛在 [SourceHosts.isOfficialSource](按主机名,不看 URL 字符串)。
*/
val fromOfficialSource: Boolean get() = SourceHosts.isOfficialSource(sourceHost)
}
/** 取址回落链里的一"级"(生产 = JS 桥 / 原生兜底 / DB 回退;单测 = 假实现)。 */
fun interface SongSourceStage {
fun resolve(): SourcedSongUrl?
}
object SongSourceFlow {
/**
* 回落链:**按顺序**执行各级取址,返回第一条非空结果(连同它的来源标志)。
*
* 顺序即 `SongApiClient` 里的 JS 主路径 → 原生兜底 → DB 回退;各级为懒执行,
* 前一级拿到地址后**不会**再跑后面的(DB 不会白查、原生不会白扫源)。
*/
@JvmStatic
fun firstAvailable(vararg stages: SongSourceStage): SourcedSongUrl? {
for (stage in stages) {
val resolved = stage.resolve() ?: continue
if (resolved.url.isNotEmpty()) return resolved
}
return null
}
/**
* 汇合点判定:**只有"地址来自官方源"才上报自建服务器**(`POST /push_download`)。
*
* * 官方源成功 → true(让自建服务器留存该曲目);
* * 自建源给出的地址 → false(服务器本来就有,再推是白推);
* * DB 兜底 / 空结果 → false。
*/
@JvmStatic
fun shouldReportToSelfHost(resolved: SourcedSongUrl?): Boolean =
resolved != null && resolved.url.isNotEmpty() && resolved.fromOfficialSource
/**
* 本轮取址彻底失败(官方源与自建源都没给出可用地址)→ 触发用户可见提示
* 「远程源已全部失效,等待新版本修复」(沿用 [Eac19WindowGuard] 的 15 分钟节流)。
*/
@JvmStatic
fun notifyAllSourcesFailed() {
Eac19WindowGuard.noteAllSourcesFailed()
}
}
@@ -50,19 +50,15 @@ object SongUrlResolver {
private const val CONFIG_TTL_MS = 30 * 60_000L
/**
* 歌曲源入口默认值:**自建源优先、官方源兜底**(2026-10-14)。
* 歌曲源入口默认值:**官方源优先、自建源兜底**(2026-10-14 反转)。
*
* * `https://mdmuse.kasugano.cn` —— 自建歌曲源(IIS 反代 HTTPS;媒体站 `md.kasugano.cn:4433`,
* 直连内网时是 `http://192.168.100.232:6677`,媒体地址为 `/media/<编号>.ts`);
* * `http://zk.ktv1.top:6677` —— 官方源兜底(自建源取不到地址时仍能回落,老用户不受影响)。
* ⚠️ **单一来源**:直接引用 [SourceHosts.DEFAULT](不再各写一份字面量)。
* 另一处字面量在 `assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(JS 无法引用 Kotlin 常量),
* 由 [`SourceHostsTest`] 与 `tools/check_source_hosts.py` 锁住一致性。
*
* ⚠️ 与 `assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(及 `CONFIG.HOST`)**逐项一致**;
* 运行时可被设置项 `KtvStore.sourceHosts` 覆盖(见 [applySourceHosts]),两份实现共读同一份设置。
*/
private val DEFAULT_HOSTS = listOf(
"https://mdmuse.kasugano.cn",
"http://zk.ktv1.top:6677",
)
private val DEFAULT_HOSTS = SourceHosts.DEFAULT
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
@@ -150,6 +146,15 @@ object SongUrlResolver {
/** 当前运行时源列表(只读快照,供日志/自检)。 */
fun currentHosts(): List<String> = synchronized(hostOrder) { hostOrder.toList() }
/**
* **默认**源列表(不受运行时 promote/demote 与设置项影响)。
*
* 就是 [SourceHosts.DEFAULT](单一来源),单独暴露是为了让"源顺序三处一致"的自检/单测
* ([SourceHostsTest] 与 `tools/check_source_hosts.py`、JS 的 `CONFIG.HOSTS`)能直接读它。
*/
@JvmStatic
fun defaultHosts(): List<String> = DEFAULT_HOSTS
private fun demote(host: String) {
synchronized(hostOrder) {
val index = hostOrder.indexOf(host)
@@ -214,8 +219,18 @@ object SongUrlResolver {
}
}
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null([resolveSourced] 的字符串版)。 */
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? =
resolveSourced(musicno, resolution, h265)?.url
/**
* 依次尝试各源,返回**可用的播放地址 + 产出它的源**(全部失败返回 null)。
*
* 为什么要带来源:汇合点([SongApiClient.getSongDownloadUrl])只在"地址来自官方源"时
* 上报自建服务器(`POST /push_download`)。来源由这里**显式返回**,
* 不由调用方读 `lastHost` 之类的环境状态、更不靠 URL 字符串猜。
*/
fun resolveSourced(musicno: String, resolution: String = "720", h265: Boolean = false): SourcedSongUrl? {
if (musicno.isBlank()) return null
val channel = fetch ?: run {
MiliLog.localWarn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
@@ -273,7 +288,7 @@ object SongUrlResolver {
"NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " +
"第${index + 1}个源 用时=${elapsed}ms",
)
return url
return SourcedSongUrl(url, host)
}
lastDetail = when {
url.isNullOrEmpty() -> "no-url"
+60 -8
View File
@@ -10,9 +10,12 @@ import java.util.LinkedHashSet
* 两边的**默认值必须逐项一致**、且都要能被设置项覆盖(见 [KtvStore.sourceHosts])。
* 解析/归一化这类"没有 IO 的判断"全部收敛到这里,JS 侧只负责"接收注入的数组"。
*
* ## 2026-10-14 自建源上线后的默认值
* 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**排在第一位**,
* 官方源 `http://zk.ktv1.top:6677` 兜底 —— 自建源取不到地址时仍能回落到官方源,不影响老用户。
* ## 2026-10-14 取址改为「官方源优先、自建源兜底」
* 官方源 `http://zk.ktv1.top:6677` **排在第一位**(成功率与稳定性最好的入口),
* 自建源(`https://mdmuse.kasugano.cn` / 媒体站 `md.kasugano.cn:4433`)**兜底** ——
* 官方源取不到地址时仍能回落,不影响老用户。
* 只有"地址来自官方源"才把该地址上报给自建服务器(见 [SourcedSongUrl.fromOfficialSource]
* 与 `SongApiClient.getSongDownloadUrl`);自建源自己给出的地址**不上报**。
*
* ⚠️ 允许清单([DEFAULT_ALLOWED_MEDIA_HOSTS])只放行"**必须是域名**"这一条限制
* (直连内网自建源时媒体地址就是裸 IP:`http://192.168.100.232:6677/media/<编号>.ts`);
@@ -22,13 +25,27 @@ import java.util.LinkedHashSet
object SourceHosts {
/**
* 默认源入口列表:**自建源优先、官方源兜底**。
* 默认源入口列表:**官方源优先、自建源兜底**。
*
* ⚠️ 与 `app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(以及 `CONFIG.HOST`)
* **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本)。
* **逐项一致**;改这里必须同时改那里([SourceHostsTest] 锁住,另有一致性比对脚本
* `tools/check_source_hosts.py`)。`SongUrlResolver.DEFAULT_HOSTS` 已收敛为**引用本常量**
* (不再各写一份),三处实际只剩两处字面量。
*/
@JvmField
val DEFAULT = listOf(
"http://zk.ktv1.top:6677",
"https://mdmuse.kasugano.cn",
)
/**
* **1.6.1(已打包、未发布)的旧默认顺序**:自建源优先、官方源兜底。
*
* 只用于一次性迁移:已落盘的 `state.json` 里 `sourceHosts` 恰好等于它时,
* 启动时改成 [DEFAULT](见 [migrateLegacyDefault])。**用户自定义的列表一律不动**。
*/
@JvmField
val LEGACY_DEFAULT = listOf(
"https://mdmuse.kasugano.cn",
"http://zk.ktv1.top:6677",
)
@@ -84,6 +101,18 @@ object SourceHosts {
@JvmStatic
fun parseAllowed(text: String?): List<String> = parseAllowedList(text?.split(SEPARATORS))
/**
* 把**已落盘**的源列表迁移到当前默认顺序:
* 只有"恰好等于 [LEGACY_DEFAULT](1.6.1 的旧默认)"时才换成 [DEFAULT],
* 其余(含用户自定义、含空 → 已由 [parseList] 回落默认)原样返回。
*
* 为什么需要它:1.6.1 已打包(虽然未发布)并可能已装到测试设备上,
* 其 `state.json` 里存着"自建源优先"的顺序 —— 不迁移的话新默认值在这些设备上不生效。
*/
@JvmStatic
fun migrateLegacyDefault(hosts: List<String>?): List<String> =
if (hosts == LEGACY_DEFAULT) DEFAULT else hosts ?: DEFAULT
/** 归一化整份允许清单;空 → [DEFAULT_ALLOWED_MEDIA_HOSTS]。 */
@JvmStatic
fun parseAllowedList(items: Collection<String>?): List<String> {
@@ -110,14 +139,37 @@ object SourceHosts {
return host.trim().lowercase().ifEmpty { null }
}
/**
* 该**主机名**(可以带端口/scheme,也可以是纯主机名)是不是自建源:
* 公网 API 站 `mdmuse.kasugano.cn` 与内网直连 `192.168.100.232` 都是同一个服务端。
*
* ⚠️ 归一化复用 [normalizeAllowedHost](去 scheme/端口/路径/用户信息、小写),
* 因此传"源入口 URL"或"裸主机名"都能正确判定。
*/
@JvmStatic
fun isSelfHostName(host: String?): Boolean {
val value = normalizeAllowedHost(host) ?: return false
return value == SELF_HOST || value == SELF_HOST_LAN
}
/**
* 该源入口是不是**自建源**(只看主机名,与 scheme/端口无关):
* 公网 API 站 `mdmuse.kasugano.cn` 与内网直连 `192.168.100.232` 都是同一个服务端。
*/
@JvmStatic
fun isSelfHost(url: String?): Boolean {
val host = hostOf(url) ?: return false
return host == SELF_HOST || host == SELF_HOST_LAN
fun isSelfHost(url: String?): Boolean = isSelfHostName(url)
/**
* 该源入口/主机名是不是**官方源**(=非自建源的源)——决定"成功取址后是否上报自建服务器"。
*
* 需求:**只有地址来自官方源时才上报**(`POST /push_download` 让自建服务器留存该曲目);
* 地址来自自建源时不上报(它本来就有这首),DB 兜底(主机名为空)同样不上报。
* 判定与"是不是自建源"互补,**不做 URL 字符串猜测**。
*/
@JvmStatic
fun isOfficialSource(source: String?): Boolean {
val value = normalizeAllowedHost(source) ?: return false
return value != SELF_HOST && value != SELF_HOST_LAN
}
/**