diff --git a/app/build.gradle b/app/build.gradle index d78c5fd..539ec4c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 40 - versionName "1.5.8" + versionCode 41 + versionName "1.5.9" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index b470bb4..cd6b710 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -216,6 +216,29 @@ class KtvJsBridge(context: Context) { return NativeBridge().request("GET", url, null, headers, timeoutMs) } + /** + * **干净 POST**(原版客户端调 `api.php` 取配置的形态,2026-10 依据 HAR 实测补上)。 + * + * HAR 实测原版:`POST http:///api.php`, + * `Content-Type: application/x-www-form-urlencoded`, + * UA 为 `Dalvik/2.1.0 (Linux; U; Android 12; SM-G9730 Build/…)`,body 为 + * `action=get_config&device_id=`;而 `i.php`/`do.php` 仍是**只有 Host+Accept** + * 的干净 GET([rawCleanGet])。这里按同样分工实现。 + */ + fun rawCleanPost(url: String, form: String, timeoutMs: Int = 8_000): String { + val headers = JSONObject() + .put("Accept", "*/*") + .put("Content-Type", "application/x-www-form-urlencoded") + .put("User-Agent", dalvikUserAgent()) + .toString() + return NativeBridge().request("POST", url, form, headers, timeoutMs) + } + + /** 原版客户端形态的 UA(Dalvik + 本机 Android 版本/型号/Build)。 */ + private fun dalvikUserAgent(): String = + "Dalvik/2.1.0 (Linux; U; Android ${android.os.Build.VERSION.RELEASE}; " + + "${android.os.Build.MODEL} Build/${android.os.Build.ID})" + fun isDestroyed(): Boolean = destroyed /** diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index 8041a0f..9559cd9 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -27,6 +27,8 @@ object SongApiClient { // 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致), // 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。 SongUrlResolver.install { url -> bridge?.rawCleanGet(url) } + // api.php 取配置走 POST(原版形态);桥不可用时为 null,此时跳过配置拉取。 + SongUrlResolver.installPost { url, form -> bridge?.rawCleanPost(url, form) } bridge?.init { ok -> ready = ok // 失败原因由 KtvJsBridge.markUnavailable() 统一上报(只记一次),此处不再重复记日志。 diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index bb9792f..1ea0fe5 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -46,6 +46,9 @@ object SongUrlResolver { */ private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L + /** `api.php` 配置缓存时长(原版每次启动/长时间后取一次即可)。 */ + private const val CONFIG_TTL_MS = 30 * 60_000L + private val DEFAULT_HOSTS = listOf( // 与其他入口同一后台、同一协议;2026-10 实测唯一仍下发真源 CDN 地址的入口,放首位。 "http://gz.ac16.vip", @@ -65,6 +68,26 @@ object SongUrlResolver { @Volatile private var fetch: ((String) -> String?)? = null + /** 干净 POST 通道(`api.php` 取配置用;未注入时跳过配置拉取)。 */ + @Volatile private var post: ((String, String) -> String?)? = null + + /** + * 源站配置(原版客户端取址前会 POST `api.php` 的 `action=get_config` 拿到)。 + * + * 实测(HAR 2026-10-08):`{"trial_enabled":true,"trial_times":2,"trial_duration":5, + * "network_verification":true,"heartbeat_interval":3600,"dialog_title":"请输入商家提供的正确卡密"}` + * —— **试用额度只有 2 次 / 5 分钟**,所以取址**不能把 9 个源全扫一遍**(会把额度瞬间打光, + * 之后就一直 `code=403 检测到异常行为`)。 + */ + private data class SourceConfig( + val trialTimes: Int, + val trialDurationMin: Int, + val networkVerification: Boolean, + ) + + @Volatile private var config: SourceConfig? = null + @Volatile private var configAt: Long = 0L + @Volatile private var sn: String = KtvJsBridge.DEVICE_SN /** 本轮取址是否遇到过 HTTP 403(源站风控的直接信号)。 */ @@ -112,6 +135,47 @@ object SongUrlResolver { } } + /** 注入"干净 POST"通道(`api.php` 取配置)。 */ + fun installPost(post: (String, String) -> String?) { + this.post = post + } + + /** + * 拉取源站配置(原版客户端在取址前会调一次 `api.php`),缓存 [CONFIG_TTL_MS]。 + * + * 用途:① 日志里给出"是否启用试用/试用次数/时长/是否需要卡密"这些**结论**; + * ② 按 `trial_times` 限制一次取址尝试的源数量,避免把试用额度瞬间耗尽。 + */ + private fun ensureConfig(host: String, mac: String) { + if (System.currentTimeMillis() - configAt < CONFIG_TTL_MS) return + val channel = post ?: return + val form = "action=get_config&device_id=$mac" + val raw = runCatching { channel("$host/api.php", form) }.getOrNull() ?: return + val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return + val status = envelope.optInt("status") + if (status !in 200..299) { + MiliLog.localWarn("SOURCE-CONFIG api.php HTTP $status") + return + } + val body = runCatching { JSONObject(envelope.optString("body")) }.getOrNull() ?: return + val c = body.optJSONObject("config") ?: body + val times = c.optInt("trial_times", 0) + val dur = c.optInt("trial_duration", 0) + val verify = c.optBoolean("network_verification", false) + config = SourceConfig(times, dur, verify) + configAt = System.currentTimeMillis() + // 远程只留一条"结论";完整 JSON 与卡密提示只写本地。 + MiliLog.info( + "SOURCE-CONFIG trial_enabled=${c.optBoolean("trial_enabled", false)} " + + "trial_times=$times trial_duration=${dur}min network_verification=$verify " + + "heartbeat=${c.optInt("heartbeat_interval", 0)}s", + ) + MiliLog.local("SOURCE-CONFIG raw=${body.toString().take(300)}") + if (verify && c.optString("dialog_title").isNotEmpty()) { + MiliLog.local("SOURCE-CONFIG 源站要求卡密:${c.optString("dialog_title")}") + } + } + /** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */ fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? { if (musicno.isBlank()) return null @@ -131,7 +195,15 @@ object SongUrlResolver { sawForbidden = false val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn) val startedAt = System.currentTimeMillis() - val hosts = synchronized(hostOrder) { hostOrder.toList() } + val allHosts = synchronized(hostOrder) { hostOrder.toList() } + // 原版取址前会先 POST api.php 取配置;这里顺带用它限制一次取址的源数量 + // (试用额度只有 trial_times 次,扫全部源会把额度瞬间打光)。 + ensureConfig(allHosts.firstOrNull().orEmpty(), mac) + val maxHosts = config?.trialTimes?.takeIf { it > 0 }?.coerceAtMost(allHosts.size) ?: allHosts.size + if (maxHosts < allHosts.size) { + MiliLog.local("NATIVE-SONG 按试用额度限制本次取址源数=$maxHosts(共 ${allHosts.size} 个)") + } + val hosts = allHosts.take(maxHosts) var lastDetail = "no-source" var adOnlyCount = 0 // 返回了地址但被判为广告/测试资源的源数 var otherFailCount = 0 // token 失败 / 无地址 / 网络异常的源数 @@ -216,7 +288,9 @@ object SongUrlResolver { val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN" val sign = md5(params + APP_KEY) val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null - if (json.optInt("code") != 200) { + val tokenCode = json.optInt("code") + if (tokenCode == 403) sawForbidden = true // 业务码 403 = 风控(见 do.php 同处注释) + if (tokenCode != 200) { MiliLog.localWarn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}") return null } @@ -240,6 +314,11 @@ object SongUrlResolver { val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null val code = json.optInt("code") if (code != 200) { + // ⚠️ 源站的"风控拒绝"是 **HTTP 200 + 业务码 403**(body: + // `{"code":403,"msg":"请求被拒绝:检测到异常行为"}`,2026-10-08 实测), + // 不是 HTTP 403 —— 以前只按 HTTP 状态判断,这类响应被当成"没有地址", + // 日志里看不出真实原因。 + if (code == 403) sawForbidden = true MiliLog.localWarn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}") return null }