取址改为官方源优先+自建源兜底,只有官方源成功才上报(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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user