根因(断网重连后再也下载不了、日志却显示"歌曲源获取成功"):
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, 仅本地打包测试。
431 lines
18 KiB
Kotlin
431 lines
18 KiB
Kotlin
package com.local.ktv
|
|
|
|
import android.util.Log
|
|
import com.liulishuo.okdownload.DownloadTask as OkDownloadTask
|
|
import com.liulishuo.okdownload.OkDownload
|
|
import com.liulishuo.okdownload.core.breakpoint.BreakpointInfo
|
|
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
|
|
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
|
|
|
|
interface DownloadCallback {
|
|
fun onDownloadStart(song: Song)
|
|
fun onDownloadProgress(song: Song, progress: Int)
|
|
fun onDownloadReadyToPlay(song: Song)
|
|
fun onDownloadComplete(song: Song, localPath: String)
|
|
fun onDownloadFailed(song: Song, error: String)
|
|
}
|
|
|
|
private val tasks = ConcurrentHashMap<String, OkDownloadTask>()
|
|
private val pending = ConcurrentHashMap.newKeySet<String>()
|
|
private val callbacks = ConcurrentHashMap<String, DownloadCallback>()
|
|
private val progress = ConcurrentHashMap<String, Int>()
|
|
private val totalLengths = ConcurrentHashMap<String, Long>()
|
|
private val forcedRetryReasons = ConcurrentHashMap<String, String>()
|
|
private val manuallyCanceled = ConcurrentHashMap.newKeySet<String>()
|
|
private val io = Executors.newFixedThreadPool(4)
|
|
private val watchdog = Executors.newSingleThreadScheduledExecutor()
|
|
|
|
@JvmStatic
|
|
fun getLocalFile(song: Song): File {
|
|
val fileName = song.filename?.takeIf(String::isNotEmpty) ?: "${song.id}.ts"
|
|
return File("${MuseDatabase.VIDEO_ROOT}/${MuseDatabase.CLOUD_SONG_DIR}", fileName)
|
|
}
|
|
|
|
@JvmStatic
|
|
fun isDownloaded(song: Song): Boolean {
|
|
val file = getLocalFile(song)
|
|
if (file.exists()) {
|
|
val inspection = SongFileValidator.inspect(file, SongFileValidator.requiresTransportStream(file))
|
|
if (!inspection.valid) {
|
|
Log.w(TAG, "Discarding invalid song file: ${file.absolutePath}, ${inspection.reason}")
|
|
SongFileValidator.forget(file)
|
|
file.delete()
|
|
song.path = null
|
|
}
|
|
}
|
|
val downloaded = file.exists()
|
|
if (downloaded) {
|
|
// Catalog rows may still contain their original cloud-songNNNN path.
|
|
// Once the unified app directory has a verified file, it is the only
|
|
// valid playback source regardless of the stale non-null catalog path.
|
|
song.path = file.absolutePath
|
|
}
|
|
return downloaded
|
|
}
|
|
|
|
@JvmStatic
|
|
fun isDownloading(song: Song): Boolean {
|
|
val key = songKey(song)
|
|
return tasks.containsKey(key) || pending.contains(key)
|
|
}
|
|
|
|
@JvmStatic
|
|
fun download(song: Song, callback: DownloadCallback?) {
|
|
if (isDownloaded(song)) {
|
|
val localFile = getLocalFile(song)
|
|
Log.i(TAG, "Using verified local file: ${localFile.absolutePath}, ${localFile.length()} bytes")
|
|
callback?.onDownloadComplete(song, localFile.absolutePath)
|
|
return
|
|
}
|
|
val key = songKey(song)
|
|
if (tasks.containsKey(key) || !pending.add(key)) {
|
|
callback?.onDownloadStart(song)
|
|
return
|
|
}
|
|
io.execute {
|
|
try {
|
|
var url = buildDownloadUrl(song)
|
|
if (url.isEmpty()) {
|
|
// 首次下载偶发解析不到地址(JS 桥冷启动/节点瞬时抖动)。短暂退避后重试一次,
|
|
// 复现“手动重试即恢复”的体验,避免第一首歌一次性报失败。
|
|
Log.w(TAG, "First URL resolve failed for ${song.title}, retrying once")
|
|
Thread.sleep(1_200)
|
|
url = buildDownloadUrl(song, forceRefresh = true)
|
|
}
|
|
if (url.isEmpty()) {
|
|
pending.remove(key)
|
|
// 取不到地址通常也是网络/源站不可达, 记下来供网络恢复后自动重试。
|
|
if (isNetworkFailure(null, 0)) networkFailed[key] = song
|
|
callback?.onDownloadFailed(song, "无法生成下载地址")
|
|
} else {
|
|
startDownloadTask(song, url, callback, 0)
|
|
}
|
|
} catch (error: Throwable) {
|
|
pending.remove(key)
|
|
callback?.onDownloadFailed(song, error.message ?: "下载初始化失败")
|
|
}
|
|
}
|
|
}
|
|
|
|
@JvmStatic
|
|
fun cancelDownload(song: Song) {
|
|
val key = songKey(song)
|
|
pending.remove(key)
|
|
tasks.remove(key)?.let { task ->
|
|
manuallyCanceled.add(key)
|
|
networkFailed.remove(key)
|
|
task.cancel()
|
|
clearBrokenDownload(task, getLocalFile(song))
|
|
}
|
|
callbacks.remove(key)
|
|
progress.remove(key)
|
|
totalLengths.remove(key)
|
|
}
|
|
|
|
@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 ->
|
|
if (!AdVideoFilter.isAdVideo(cached)) return cached
|
|
// 缓存的下载地址是广告视频,清掉并重新解析(广告会被桥接层屏蔽/替换)。
|
|
Log.w(TAG, "丢弃广告缓存地址: ${song.title}")
|
|
song.downloadUrl = null
|
|
}
|
|
}
|
|
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
|
|
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
|
|
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
|
|
val srcHost = SongApiClient.lastSongSourceHost()
|
|
if (url.isNotEmpty()) {
|
|
song.downloadUrl = url
|
|
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
|
|
MiliLog.info(
|
|
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost",
|
|
)
|
|
} else {
|
|
Log.w(TAG, "Unable to get download URL: ${song.title}")
|
|
MiliLog.error(
|
|
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址",
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startDownloadTask(song: Song, url: String, callback: DownloadCallback?, attempt: Int) {
|
|
val key = songKey(song)
|
|
val target = getLocalFile(song)
|
|
val partial = File(target.parentFile, "${target.name}.download")
|
|
target.parentFile?.mkdirs()
|
|
if (attempt > 0) partial.delete()
|
|
val task = OkDownloadTask.Builder(url, partial.parentFile!!)
|
|
.setFilename(partial.name)
|
|
.setPassIfAlreadyCompleted(false)
|
|
.setMinIntervalMillisCallbackProcess(250)
|
|
.build()
|
|
tasks[key] = task
|
|
pending.remove(key)
|
|
callback?.let { callbacks[key] = it }
|
|
progress[key] = 0
|
|
totalLengths[key] = 0
|
|
val downloaded = AtomicLong(0)
|
|
val lastActivityAt = AtomicLong(System.currentTimeMillis())
|
|
var httpResponseCode = 0
|
|
|
|
scheduleStallCheck(song, task, lastActivityAt, attempt)
|
|
task.enqueue(object : DownloadListener2() {
|
|
override fun taskStart(task: OkDownloadTask) {
|
|
lastActivityAt.set(System.currentTimeMillis())
|
|
callbacks[key]?.onDownloadStart(song)
|
|
}
|
|
|
|
override fun connectEnd(
|
|
task: OkDownloadTask,
|
|
blockCount: Int,
|
|
responseCode: Int,
|
|
responseHeaderFields: MutableMap<String, MutableList<String>>,
|
|
) {
|
|
httpResponseCode = responseCode
|
|
lastActivityAt.set(System.currentTimeMillis())
|
|
}
|
|
|
|
override fun downloadFromBeginning(task: OkDownloadTask, info: BreakpointInfo, cause: ResumeFailedCause) {
|
|
totalLengths[key] = info.totalLength
|
|
}
|
|
|
|
override fun downloadFromBreakpoint(task: OkDownloadTask, info: BreakpointInfo) {
|
|
totalLengths[key] = info.totalLength
|
|
downloaded.set(info.totalOffset)
|
|
}
|
|
|
|
override fun fetchProgress(task: OkDownloadTask, blockIndex: Int, increaseBytes: Long) {
|
|
if (increaseBytes > 0) lastActivityAt.set(System.currentTimeMillis())
|
|
val total = totalLengths[key] ?: return
|
|
if (total <= 0) return
|
|
val value = (downloaded.addAndGet(increaseBytes) * 100 / total).toInt().coerceIn(0, 98)
|
|
progress[key] = value
|
|
callbacks[key]?.onDownloadProgress(song, value)
|
|
}
|
|
|
|
override fun taskEnd(task: OkDownloadTask, cause: EndCause, realCause: Exception?) {
|
|
tasks.remove(key, task)
|
|
totalLengths.remove(key)
|
|
val cb = callbacks.remove(key)
|
|
val forcedReason = forcedRetryReasons.remove(key)
|
|
if (manuallyCanceled.remove(key)) return
|
|
if (cause != EndCause.COMPLETED) {
|
|
val retryable = forcedReason != null || cause != EndCause.CANCELED
|
|
if (retryable && attempt < MAX_RETRY_COUNT) {
|
|
clearBrokenDownload(task, target)
|
|
val response = if (httpResponseCode > 0) " HTTP $httpResponseCode" else ""
|
|
retry(song, cb, attempt + 1, forcedReason ?: "${cause.name}$response")
|
|
} 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
|
|
}
|
|
pending.add(key)
|
|
progress[key] = 99
|
|
cb?.onDownloadProgress(song, 99)
|
|
io.execute { finalizeDownload(song, task, target, partial, cb, attempt) }
|
|
}
|
|
})
|
|
}
|
|
|
|
private fun finalizeDownload(
|
|
song: Song,
|
|
task: OkDownloadTask,
|
|
target: File,
|
|
partial: File,
|
|
callback: DownloadCallback?,
|
|
attempt: Int,
|
|
) {
|
|
val key = songKey(song)
|
|
try {
|
|
var downloadedFile = task.file ?: partial
|
|
if (!downloadedFile.exists() || downloadedFile.length() < MIN_VALID_FILE_SIZE) {
|
|
val size = downloadedFile.takeIf(File::exists)?.length() ?: 0L
|
|
clearBrokenDownload(task, target)
|
|
retryOrFail(song, callback, attempt, "file too small: $size", "下载文件异常,已自动清理")
|
|
return
|
|
}
|
|
if (TsDecryptor.isEncrypted(downloadedFile)) {
|
|
val decrypted = File(downloadedFile.parentFile, "${downloadedFile.name}.decrypted")
|
|
decrypted.delete()
|
|
val decryptedOk = TsDecryptor.decryptFile(downloadedFile, decrypted)
|
|
val replaced = decryptedOk && downloadedFile.delete() && decrypted.renameTo(downloadedFile)
|
|
if (!replaced) {
|
|
clearBrokenDownload(task, target)
|
|
retryOrFail(song, callback, attempt, "decrypt failed", "歌曲解密失败,异常文件已删除")
|
|
return
|
|
}
|
|
downloadedFile = task.file ?: partial
|
|
}
|
|
val inspection = SongFileValidator.inspect(
|
|
downloadedFile,
|
|
SongFileValidator.requiresTransportStream(target),
|
|
)
|
|
if (!inspection.valid) {
|
|
Log.w(TAG, "Rejecting downloaded media for ${song.title}: ${inspection.reason}")
|
|
SongFileValidator.forget(downloadedFile)
|
|
clearBrokenDownload(task, target)
|
|
pending.remove(key)
|
|
progress.remove(key)
|
|
val message = inspection.durationMs?.let { duration ->
|
|
"服务器返回试听片段(${duration / 1000}秒),未保存也未加入已点"
|
|
} ?: "下载文件不是有效歌曲视频,未保存也未加入已点"
|
|
callback?.onDownloadFailed(song, message)
|
|
return
|
|
}
|
|
if (target.exists() && !target.delete()) {
|
|
pending.remove(key)
|
|
progress.remove(key)
|
|
callback?.onDownloadFailed(song, "无法替换旧的歌曲文件")
|
|
return
|
|
}
|
|
if (!downloadedFile.renameTo(target)) {
|
|
clearBrokenDownload(task, target)
|
|
retryOrFail(song, callback, attempt, "rename failed", "下载文件落盘失败")
|
|
return
|
|
}
|
|
pending.remove(key)
|
|
progress.remove(key)
|
|
networkFailed.remove(key)
|
|
song.path = target.absolutePath
|
|
callback?.onDownloadProgress(song, 100)
|
|
callback?.onDownloadComplete(song, target.absolutePath)
|
|
} catch (error: Throwable) {
|
|
Log.e(TAG, "Finalizing download failed: ${song.title}", error)
|
|
clearBrokenDownload(task, target)
|
|
retryOrFail(song, callback, attempt, error.message ?: "finalize failed", "下载文件处理失败")
|
|
}
|
|
}
|
|
|
|
private fun retryOrFail(
|
|
song: Song,
|
|
callback: DownloadCallback?,
|
|
attempt: Int,
|
|
reason: String,
|
|
terminalMessage: String,
|
|
) {
|
|
if (attempt < MAX_RETRY_COUNT) {
|
|
retry(song, callback, attempt + 1, reason)
|
|
} else {
|
|
val key = songKey(song)
|
|
pending.remove(key)
|
|
progress.remove(key)
|
|
callback?.onDownloadFailed(song, terminalMessage)
|
|
}
|
|
}
|
|
|
|
private fun scheduleStallCheck(
|
|
song: Song,
|
|
task: OkDownloadTask,
|
|
lastActivityAt: AtomicLong,
|
|
attempt: Int,
|
|
) {
|
|
watchdog.schedule({
|
|
val key = songKey(song)
|
|
if (tasks[key] !== task) return@schedule
|
|
val idleMs = System.currentTimeMillis() - lastActivityAt.get()
|
|
if (idleMs >= STALL_TIMEOUT_MS) {
|
|
Log.w(TAG, "Download stalled for ${idleMs}ms: ${song.title}, attempt=$attempt")
|
|
forcedRetryReasons[key] = "stalled for ${idleMs}ms"
|
|
task.cancel()
|
|
} else {
|
|
scheduleStallCheck(song, task, lastActivityAt, attempt)
|
|
}
|
|
}, STALL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
|
}
|
|
|
|
private fun clearBrokenDownload(task: OkDownloadTask, target: File) {
|
|
runCatching { OkDownload.with().breakpointStore().remove(task.id) }
|
|
val partial = task.file ?: File(target.parentFile, "${target.name}.download")
|
|
if (partial.absolutePath != target.absolutePath) partial.delete()
|
|
File(partial.parentFile, "${partial.name}.decrypted").delete()
|
|
}
|
|
|
|
private fun retry(song: Song, callback: DownloadCallback?, attempt: Int, reason: String) {
|
|
Log.w(TAG, "Retrying ${song.title}, attempt=$attempt, reason=$reason")
|
|
val key = songKey(song)
|
|
pending.add(key)
|
|
progress[key] = 0
|
|
callback?.onDownloadProgress(song, 0)
|
|
song.downloadUrl = null
|
|
SongApiClient.clearTokenCache()
|
|
io.execute {
|
|
Thread.sleep((attempt * 750L).coerceAtMost(2_250L))
|
|
val url = runCatching { buildDownloadUrl(song, forceRefresh = true) }.getOrDefault("")
|
|
if (url.isEmpty()) {
|
|
if (attempt < MAX_RETRY_COUNT) {
|
|
retry(song, callback, attempt + 1, "refresh download URL failed")
|
|
} else {
|
|
pending.remove(key)
|
|
progress.remove(key)
|
|
callback?.onDownloadFailed(song, "刷新下载地址失败")
|
|
}
|
|
} else {
|
|
startDownloadTask(song, url, callback, attempt)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 因网络原因失败的歌曲(网络恢复后自动重试一次)。 */
|
|
private val networkFailed = Collections.synchronizedMap(LinkedHashMap<String, Song>())
|
|
|
|
private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty)
|
|
?: song.filename?.takeIf(String::isNotEmpty)
|
|
?: song.title.orEmpty()
|
|
|
|
}
|