From 942ec527f17ab149437dfb076858678b67d6d365 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Mon, 21 Sep 2026 03:34:29 +0800 Subject: [PATCH] =?UTF-8?q?V1.4.0:=20=E4=BF=AE=E5=A4=8D=E6=96=AD=E7=BD=91?= =?UTF-8?q?=E9=87=8D=E8=BF=9E=E5=90=8E=E6=97=A0=E6=B3=95=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=20+=20=E7=BD=91=E7=BB=9C=E7=8A=B6=E6=80=81=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因(断网重连后再也下载不了、日志却显示"歌曲源获取成功"): SongApiClient.clearTokenCache() 一直是**空实现**。JS 侧 window.api.tokens 与 _apiInitPromise 因此被一直复用; 而歌曲源按「出口 IP + device」识别、试用窗口 也记在出口 IP 上(见 Eac19WindowGuard), 断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功, 真正下载却 403/失败。 1) 网络状态检测(新增 KtvNetworkMonitor) 走 ConnectivityManager 回调(不轮询)维护全局 online, 状态变化才回调主线程; 全局可读 online 供下载层判断失败是否由断网引起。 2) 播放页右上角「网络未连接」提示 全屏播放页控制层 header 内、标题右侧新增红色徽标(不遮挡右上角二维码), 断网显示、恢复自动隐藏。 3) 网络恢复/换网后的统一维护 refreshNetworkState() - 重建歌曲源会话: clearTokenCache() 真正下发 KtvJsBridge.resetApiSession() (置空 window.api 与 _apiInitPromise, 下次取址重新构造 API 实例、回到默认源顺序 并重新登录拿新 token); - 复位 Eac19WindowGuard 窗口守卫; 重解析网卡 MAC + 刷新日志上下文; - 重启本地点歌服务并刷新二维码; - 自动重试断网期间失败的下载(最多 8 首)。 断网恢复走该路径(带提示), 换网换 IP 也走同一路径(静默)。 4) 下载层网络失败跟踪(SongOkDownloadManager) 结合 KtvNetworkMonitor.online 与异常类型(UnknownHost/Connect/SocketTimeout/ NoRouteToHost/SSL/EOF)及 HTTP 0 判定网络类失败, 记入 networkFailed; 成功后清除、手动取消不入集合; retryNetworkFailed() 供恢复时调用。 5) 顺带修掉一个隐患: 离线时 localIp() 为空会让 remoteNetworkMonitor 每 5s 误判 "IP 变化"而反复重启本地点歌服务; 现已加空值短路, 离线只等网络回调。 版本号 1.3.9 -> 1.4.0(versionCode 15 -> 16)。按约定本次不发布 Release, 仅本地打包测试。 --- AGENTS.md | 19 ++- app/build.gradle | 4 +- .../main/java/com/local/ktv/KtvJsBridge.kt | 33 +++++ .../java/com/local/ktv/KtvNetworkMonitor.kt | 121 ++++++++++++++++++ .../main/java/com/local/ktv/MainActivity.kt | 85 +++++++++++- .../main/java/com/local/ktv/SongApiClient.kt | 16 ++- .../com/local/ktv/SongOkDownloadManager.kt | 52 ++++++++ 7 files changed, 321 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/local/ktv/KtvNetworkMonitor.kt diff --git a/AGENTS.md b/AGENTS.md index 773f2de..6db5c7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,7 +108,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.3.9"` / `versionCode 15` +- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16` - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) ## 三、关键实现约束(避免踩坑) @@ -139,6 +139,23 @@ $BT/apksigner verify 目标名.apk 客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error` 保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、 设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率 +- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询) + 维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责: + - 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、 + 标题右侧,不遮挡右上角二维码); + - 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、 + 复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。 + 换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。 + ⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**: + `SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与 + `_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上, + 断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。 + 现在 `clearTokenCache()` 会真正下发 `KtvJsBridge.resetApiSession()` + (置空 `window.api` 与 `_apiInitPromise`,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。 + 下载层的网络类失败由 `SongOkDownloadManager` 结合 `KtvNetworkMonitor.online` 与异常类型/HTTP 0 + 判定并记入 `networkFailed`,网络恢复后由 `retryNetworkFailed()` 自动重试(手动取消的不入集合)。 + ⚠️ 顺带修掉一个隐患:离线时 `localIp()` 为空,原 `remoteNetworkMonitor` 会每 5s 误判「IP 变化」 + 而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。 - **播放界面右上角二维码**:跟随全屏控制层一起出现/隐藏 —— 在 `setFullScreenChromeVisible()` 里同步 `fullScreenQrOverlay` 的可见性,`updateFullScreenQrOverlay()` 新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏 diff --git a/app/build.gradle b/app/build.gradle index 8d7dba1..98999bc 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 15 - versionName "1.3.9" + versionCode 16 + versionName "1.4.0" 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 bc13186..8fdca7e 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -47,6 +47,22 @@ class KtvJsBridge(context: Context) { // sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。 private val DEVICE_MAC: String get() = DeviceMac.effective() private const val DEVICE_SN = "2d7bb4a7d7f7" + + /** + * 断网重连后重置歌曲源会话。 + * + * 源站按「出口 IP + device」识别,试用窗口也记在出口 IP 上(见 Eac19WindowGuard)。 + * 断网/重连(尤其换了出口 IP)后旧 token 已失效,继续复用会表现为 + * **「取址看起来成功、实际下载 403/失败」**。这里把整个 API 实例与初始化 Promise + * 都清掉:下一次取址会重新 new KtvApi(回到默认源顺序)并重新登录拿新 token。 + */ + private const val RESET_SESSION_JS = """(function(){ + try { + window.api = null; + window._apiInitPromise = null; + } catch (e) {} + return 'ok'; + })()""" } private val appContext = context.applicationContext @@ -119,6 +135,23 @@ class KtvJsBridge(context: Context) { fun isDestroyed(): Boolean = destroyed + /** + * 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。 + * + * 置空 `window.api` 与 `window._apiInitPromise`,下一次取址会重新构造 API 实例 + * (恢复默认源顺序)并重新登录,避免继续使用失效 token。 + */ + fun resetApiSession() { + val view = webView + if (!ready || destroyed || view == null) return + mainHandler.post { + runCatching { + view.evaluateJavascript(RESET_SESSION_JS, null) + Log.i(TAG, "reset source api session") + }.onFailure { Log.w(TAG, "reset source api session failed", it) } + } + } + fun getSongUrl( musicNo: String, resolution: String, diff --git a/app/src/main/java/com/local/ktv/KtvNetworkMonitor.kt b/app/src/main/java/com/local/ktv/KtvNetworkMonitor.kt new file mode 100644 index 0000000..369e1df --- /dev/null +++ b/app/src/main/java/com/local/ktv/KtvNetworkMonitor.kt @@ -0,0 +1,121 @@ +package com.local.ktv + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log + +/** + * 设备网络状态监测(断网 / 恢复),供「网络未连接」提示与断网重连后的会话重建使用。 + * + * 只依赖 `ConnectivityManager` 的实时回调,不轮询: + * - `onAvailable` / `onLost` / `onCapabilitiesChanged` 维护「缺省网络是否具备互联网能力」; + * - 状态**变化**时回调上层(已切到主线程),由上层处理提示与恢复动作。 + * + * 注意区分两件事: + * - 这里判断的是**系统级网络可用性**(有没有网); + * - 歌曲源的「试用窗口 / 风控」是按**出口 IP** 计的(见 [Eac19WindowGuard]), + * 换网/重连后即使有网也可能需要重新建立会话 —— 这由上层在恢复回调里处理。 + * + * 全局可读 [online],便于下载层判断失败是否由断网引起。 + */ +object KtvNetworkMonitor { + private const val TAG = "KtvNetworkMonitor" + + /** 当前是否有可用网络(默认 true,避免启动瞬间误报断网)。 */ + @Volatile + var online: Boolean = true + private set + + private val mainHandler = Handler(Looper.getMainLooper()) + private var manager: ConnectivityManager? = null + private var callback: ConnectivityManager.NetworkCallback? = null + + /** 开始监测;[onChange] 只在状态真正变化时回调(主线程)。 */ + fun start(context: Context, onChange: (online: Boolean) -> Unit) { + if (callback != null) return + val cm = context.applicationContext + .getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + manager = cm + val initial = currentOnline(cm) + online = initial + val cb = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + publish(true, onChange) + } + + override fun onLost(network: Network) { + // 掉了一个网络不代表彻底没网(可能还有以太网/其它网络),重新判定一次。 + publish(currentOnline(manager), onChange) + } + + override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) { + publish(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), onChange) + } + } + callback = cb + val registered = runCatching { + cm?.registerNetworkCallback( + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(), + cb, + ) + true + }.getOrElse { + Log.w(TAG, "registerNetworkCallback failed: ${it.message}") + false + } + if (!registered) { + callback = null + manager = null + } + Log.i(TAG, "monitor started, online=$initial registered=$registered") + onChange(initial) + } + + fun stop() { + val cb = callback ?: return + runCatching { manager?.unregisterNetworkCallback(cb) } + .onFailure { Log.w(TAG, "unregister failed: ${it.message}") } + callback = null + manager = null + Log.i(TAG, "monitor stopped") + } + + private fun publish(value: Boolean, onChange: (Boolean) -> Unit) { + mainHandler.post { + if (value == online) return@post + online = value + Log.i(TAG, "network state changed: online=$value") + onChange(value) + } + } + + /** 主动复检一次(例如从后台回到前台),有变化才回调。 */ + fun refresh(onChange: (online: Boolean) -> Unit) { + publish(currentOnline(manager), onChange) + } + + private fun currentOnline(cm: ConnectivityManager?): Boolean { + val manager = cm ?: return true + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = manager.activeNetwork ?: return false + val caps = manager.getNetworkCapabilities(network) ?: return false + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } else { + @Suppress("DEPRECATION") + manager.activeNetworkInfo?.isConnected == true + } + } catch (e: Throwable) { + Log.w(TAG, "currentOnline failed: ${e.message}") + true + } + } +} diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 25f4ca6..a6fabfd 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -164,19 +164,24 @@ class MainActivity : AppCompatActivity() { } private var mobileQrOverlay: View? = null private var fullScreenQrOverlay: View? = null + /** 播放页面右上角「网络未连接」提示。 */ + private var fullScreenNetworkBadge: TextView? = null private var updateCheckStarted = false private var compactUi = false private val remoteNetworkMonitor = object : Runnable { override fun run() { val ip = localIp().takeIf { LocalRemoteServer.isPrivateIpv4(it) } + // 无可用局域网 IP(离线/切换中): 不做重启与刷新, 避免每 5s 空转(网络恢复由 + // KtvNetworkMonitor 的回调负责)。 + if (ip.isNullOrEmpty()) { + main.postDelayed(this, 5000) + return + } if (ip != remoteServer.address()) { remoteServer.stop() startRemoteServer() - updateMobileQrOverlay() - updateFullScreenQrOverlay() - // 网络切换后重新解析当前网卡 MAC,并同步日志上报的 IP/设备标识。 - DeviceMac.refresh() - refreshLogDeviceContext() + // 换网/换 IP 后出口 IP 可能变化, 歌曲源会话必须重建(见 refreshNetworkState)。 + refreshNetworkState("ip-change:$ip", notify = false) } main.postDelayed(this, 5000) } @@ -498,6 +503,8 @@ class MainActivity : AppCompatActivity() { startRemoteServer() updateMobileQrOverlay() main.postDelayed(remoteNetworkMonitor, 5000) + // 网络状态监测: 断网提示 + 恢复后重建歌曲源会话/重试下载。 + KtvNetworkMonitor.start(applicationContext) { online -> onNetworkStateChanged(online) } // 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。 sendDeviceHeartbeat(boot = true) main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS) @@ -682,6 +689,7 @@ class MainActivity : AppCompatActivity() { main.removeCallbacks(homeBannerRunnable) main.removeCallbacks(autoFullscreenRunnable) main.removeCallbacks(remoteNetworkMonitor) + KtvNetworkMonitor.stop() stopRecording(false) unregisterMicInputWatcher() micActivator.stop() @@ -2234,6 +2242,22 @@ class MainActivity : AppCompatActivity() { val song = label(currentAndNextText(), 18, Color.WHITE) fullScreenSongInfo = song header.addView(song, LinearLayout.LayoutParams(0, dp(48), 1f)) + // 播放页面右上角「网络未连接」提示(仅断网时显示, 网络恢复自动消失)。 + val networkBadge = label("● 网络未连接", 14, Color.WHITE).apply { + gravity = Gravity.CENTER + background = GradientDrawable().apply { + cornerRadius = dp(11).toFloat() + setColor(Color.rgb(198, 40, 74)) + setStroke(dp(1), Color.argb(140, 255, 255, 255)) + } + includeFontPadding = false + visibility = if (KtvNetworkMonitor.online) View.GONE else View.VISIBLE + } + fullScreenNetworkBadge = networkBadge + header.addView( + networkBadge, + LinearLayout.LayoutParams(dp(if (compactUi) 108 else 124), dp(28)).apply { marginEnd = dp(8) }, + ) val back = label("返回", 15, Color.WHITE) back.setGravity(Gravity.CENTER) back.setBackgroundResource(R.drawable.bg_btn_glass) @@ -2400,6 +2424,7 @@ class MainActivity : AppCompatActivity() { fullScreenControls = null fullScreenProgressRow = null fullScreenQrOverlay = null + fullScreenNetworkBadge = null fullScreenChromeVisible = false main.removeCallbacks(hideFullScreenChromeRunnable) scheduleAutoFullscreen() @@ -3579,6 +3604,56 @@ class MainActivity : AppCompatActivity() { /** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */ private fun deviceMac(): String = DeviceMac.effective() + /** + * 设备网络状态变化处理。 + * + * 断网: 播放页右上角显示「网络未连接」。 + * 恢复: 刷新对网络敏感的全部状态, 保证歌曲源能重新取址并正常下载/播放 —— + * 1. 重新解析当前网卡 MAC 与日志上报 IP; + * 2. **重置歌曲源会话**(清 JS 侧 token 与 API 实例)—— 源站按出口 IP 计试用窗口, + * 断网/重连后旧 token 失效,若继续复用会出现「地址取到了但下载 403/失败」; + * 3. 复位歌曲源窗口守卫,便于下一次到期能再次提示; + * 4. 拉起本地点歌服务并刷新二维码; + * 5. 自动重试断网期间失败的下载。 + */ + private fun onNetworkStateChanged(online: Boolean) { + fullScreenNetworkBadge?.visibility = if (online) View.GONE else View.VISIBLE + if (!online) { + status?.text = "网络未连接" + MiliLog.warn("NET-DOWN 设备网络已断开") + return + } + MiliLog.notice("NET-UP 设备网络已恢复, 重建歌曲源会话") + if (!remoteServer.isRunning()) startRemoteServer() + refreshNetworkState("net-up", notify = true) + } + + /** + * 网络路径变化后的统一维护(断网恢复 / 换网换 IP 都走这里)。 + * + * 关键在于**重建歌曲源会话**:源站按「出口 IP + device」识别,并把试用窗口记在出口 IP 上, + * 断网重连或换网后旧 token 已失效 —— 不重置就会出现「日志提示取址成功、实际下载 + * 403/失败」。同时复位窗口守卫、刷新二维码与日志上下文,并自动重试断网期间失败的下载。 + * + * @param notify 是否弹提示(断网恢复时提示,换 IP 时静默) + */ + private fun refreshNetworkState(reason: String, notify: Boolean) { + DeviceMac.refresh() + refreshLogDeviceContext() + SongApiClient.resetSourceSession() + Eac19WindowGuard.noteUrlOk() + updateMobileQrOverlay() + updateFullScreenQrOverlay() + val retried = SongOkDownloadManager.retryNetworkFailed() + MiliLog.notice("NET-REFRESH reason=$reason retried=$retried") + if (retried > 0) { + status?.text = "网络已恢复,正在重试 $retried 首未完成的下载" + if (notify) toast("网络已恢复,正在重试 $retried 首未完成的下载") + } else if (notify) { + status?.text = "网络已恢复" + } + } + /** 远程日志: 把设备网卡 IP 与设备标识同步给日志客户端(GlogCenter 的 clientip/user/traceid)。 */ private fun refreshLogDeviceContext() { MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac()) diff --git a/app/src/main/java/com/local/ktv/SongApiClient.kt b/app/src/main/java/com/local/ktv/SongApiClient.kt index 5172cd5..2792c9f 100644 --- a/app/src/main/java/com/local/ktv/SongApiClient.kt +++ b/app/src/main/java/com/local/ktv/SongApiClient.kt @@ -106,8 +106,22 @@ object SongApiClient { return false } + /** + * 重置歌曲源会话(清 JS 侧 token / API 实例)。 + * + * 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是 + * 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。 + */ @JvmStatic - fun clearTokenCache() {} + fun clearTokenCache() { + bridge?.resetApiSession() + } + + /** 语义更明确的别名:断网恢复后重建歌曲源会话。 */ + @JvmStatic + fun resetSourceSession() { + bridge?.resetApiSession() + } /** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */ @JvmStatic diff --git a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt index b93b69c..f93f9c2 100644 --- a/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt +++ b/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt @@ -8,6 +8,8 @@ import com.liulishuo.okdownload.core.cause.EndCause import com.liulishuo.okdownload.core.cause.ResumeFailedCause import com.liulishuo.okdownload.core.listener.DownloadListener2 import java.io.File +import java.util.Collections +import java.util.LinkedHashMap import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -16,6 +18,8 @@ import java.util.concurrent.atomic.AtomicLong object SongOkDownloadManager { private const val TAG = "SongDownload" const val MIN_VALID_FILE_SIZE = SongFileValidator.MIN_VALID_FILE_SIZE + /** 网络恢复后最多自动重试几首(防止一次拉起过多并发下载)。 */ + private const val MAX_NETWORK_RETRY = 8 private const val MAX_RETRY_COUNT = 3 private const val STALL_TIMEOUT_MS = 25_000L @@ -96,6 +100,8 @@ object SongOkDownloadManager { } if (url.isEmpty()) { pending.remove(key) + // 取不到地址通常也是网络/源站不可达, 记下来供网络恢复后自动重试。 + if (isNetworkFailure(null, 0)) networkFailed[key] = song callback?.onDownloadFailed(song, "无法生成下载地址") } else { startDownloadTask(song, url, callback, 0) @@ -113,6 +119,7 @@ object SongOkDownloadManager { pending.remove(key) tasks.remove(key)?.let { task -> manuallyCanceled.add(key) + networkFailed.remove(key) task.cancel() clearBrokenDownload(task, getLocalFile(song)) } @@ -124,6 +131,43 @@ object SongOkDownloadManager { @JvmStatic fun getDownloadProgress(song: Song): Int = progress[songKey(song)] ?: 0 + /** + * 网络恢复后自动重试「因网络原因失败」的下载(最多 [MAX_NETWORK_RETRY] 首)。 + * + * 断网期间的失败大多是连不上/超时/HTTP 0,属于可恢复;恢复后重试一次,避免用户 + * 手动重新点歌。手动取消的歌不会进入这个集合。 + * + * @return 实际发起重试的数量 + */ + @JvmStatic + fun retryNetworkFailed(): Int { + val retry = synchronized(networkFailed) { + networkFailed.values.toList().also { networkFailed.clear() } + } + if (retry.isEmpty()) return 0 + val limited = retry.take(MAX_NETWORK_RETRY) + Log.i(TAG, "network recovered, retrying ${limited.size} failed download(s)") + io.execute { + limited.forEach { song -> runCatching { download(song, callbacks[songKey(song)]) } } + } + return limited.size + } + + /** 判断一个失败是否属于「网络原因」(断网/超时/连不上/HTTP 0)。 */ + private fun isNetworkFailure(realCause: Exception?, httpCode: Int): Boolean { + if (!KtvNetworkMonitor.online) return true + if (httpCode == 0) return true + val cause = realCause ?: return false + return cause is java.net.UnknownHostException || + cause is java.net.ConnectException || + cause is java.net.SocketTimeoutException || + cause is java.net.NoRouteToHostException || + cause is java.net.SocketException || + cause is javax.net.ssl.SSLException || + cause is java.io.EOFException || + cause is java.io.InterruptedIOException + } + private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String { if (!forceRefresh) { song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached -> @@ -222,6 +266,10 @@ object SongOkDownloadManager { } else { pending.remove(key) progress.remove(key) + if (isNetworkFailure(realCause, httpResponseCode)) { + networkFailed[key] = song + Log.w(TAG, "download failed by network: ${song.title} (${cause.name})") + } cb?.onDownloadFailed(song, cause.name + (realCause?.message?.let { ": $it" } ?: "")) } return @@ -292,6 +340,7 @@ object SongOkDownloadManager { } pending.remove(key) progress.remove(key) + networkFailed.remove(key) song.path = target.absolutePath callback?.onDownloadProgress(song, 100) callback?.onDownloadComplete(song, target.absolutePath) @@ -371,6 +420,9 @@ object SongOkDownloadManager { } } + /** 因网络原因失败的歌曲(网络恢复后自动重试一次)。 */ + private val networkFailed = Collections.synchronizedMap(LinkedHashMap()) + private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty) ?: song.filename?.takeIf(String::isNotEmpty) ?: song.title.orEmpty()