Files
maidong-ktv/app/src/main/java/com/local/ktv/SongOkDownloadManager.kt
T
zijunchiang eadc2d4de1 V1.5.0: 修复"公放切歌失效"——上一版清空歌曲缓存把公放曲库删了(新设备回归)
现象: 新设备公放时按「切歌」没有任何反应。

根因(我上一版引入的回归):
- 1.4.10 为了让"被当成歌曲缓存下来的广告视频"消失,在启动时做了一次
  **整目录清理**(删 cloud-song 下所有 .ts/.ls/.mp4/.mkv)。
- 但 MuseDatabase.songDirectories() **只有 cloudSongsDir 一个目录**,
  SongOkDownloadManager.getLocalFile() 也落在那里 —— 那等于**把公放曲库整个删掉**。
- 后果链: 库空 → playRandomLocalSong() 里 allLocalSongs 为空 →
  RandomPlayQueue.next() 返回 null → 状态栏"本地暂无可播放歌曲"、currentSong 保持 null
  → 按「切歌」时 advancePlayback 落进「取消随机续播、进入空闲态」分支 → 按钮无反应。
  (RandomPlayQueue.next 本身有"只剩一首也能播"的兜底,队列逻辑没问题。)

修复:
- 删除整目录清理,改为 purgeInjectedAdDuplicatesOnce(): **只删能证明是注入广告的文件** ——
  广告对每首歌下发同一个视频,特征是"多首不同歌曲的本地文件内容完全相同"
  (按文件大小 + 头部 4MB 指纹分组,只清理 size>=2 的完全相同组)。
  正常歌曲内容各不相同,绝不会被误删;公放曲库得以保留。

同时按用户要求,复查"老设备适配期间改动对新设备的影响",另修正两处:
- SongApiClient.getSongDownloadUrl 的主线程分支: 1.4.7 起会先走 resolveNatively()
  (最多 7 个源 × 8 秒超时的阻塞网络请求)—— 一旦有主线程调用者就会卡死 UI 到 ANR。
  现在主线程只做本地 DB 兜底,网络取址一律留在后台线程。
- KtvJsBridge 裸 Socket 的 HTTPS 分支: 1.4.6 改用了无参 createSocket(),拿到的 SSLSocket
  **不带主机名**(影响 SNI 与证书主机名校验,共享 CDN/反代多证书时会握手失败)。
  改为先 connect 普通 socket(保留显式连接超时)再 createSocket(plain, host, port, true)。
- buildDownloadUrl 的文件名比对只在点歌编号"可信"时启用: 只有从 song.filename 推出的编号才
  参与比对;song.id 可能是数据库主键,拿它比对会误杀正常地址(真源被判"文件名不符")。
  (另已实测 7 个源: 可用的 5 个真源 URL 文件名全部严格等于点歌编号,规则不会误杀。)

顺带记录用户给出的版本号约定(已写入 AGENTS.md):
versionName = <major>.<minor>.<patch>,patch 0~10、minor 1~10;
1.4.10 之后是 1.5.0,1.10.1 之后是 2.1.0;versionCode 始终 +1。

版本 1.4.10(30) → 1.5.0(31);签名证书不变。测试 8 例全通过。
2026-09-26 21:25:37 +08:00

546 lines
25 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
/** 曲库视频目录下会被当作"已下载歌曲"的文件后缀(用于一次性清理历史广告缓存)。 */
private val MEDIA_EXTENSIONS = setOf("ts", "ls", "mp4", "mkv")
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>()
// ConcurrentHashMap.newKeySet() 是 API 24+(低版本 NoSuchMethodError);
// Collections.newSetFromMap 自 API 9 起可用,且返回普通 Set,add/remove/contains 均成立于 API 1。
private val pending: MutableSet<String> =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
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: MutableSet<String> =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
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
}
/**
* 一次性清理"同一段视频被当成多首不同歌曲"的注入广告。
*
* 背景(2026-09-26 事故):1.4.9 及以前,JS 取址返回的地址在 `buildDownloadUrl` 的成功分支里
* **完全没有过广告校验**,广告视频(如 `gz.ac16.vip/ad_files/my_ad_video.ts`)会被当成歌曲
* 下载并落盘;它又能通过 [SongFileValidator] 的内容校验(≥6MB / ≥60s / TS 包完整),
* 而且 [isDownloaded] 会直接复用该文件、连取址都不会再发生 —— 广告会一直播下去。
*
* ⚠️ **不能像 1.4.10 那样直接清空整个歌曲目录**:`MuseDatabase.songDirectories()` 只有
* `cloudSongsDir` 一个目录,那等于把**公放曲库整个删掉** —— 库里没歌,
* `playRandomLocalSong()` 选不出候选、`currentSong` 保持 null,
* 于是「切歌」落进空闲态分支、按了没反应(新设备上的回归)。
*
* 这里改为**只删真正能证明是注入广告的文件**:广告对每首歌都下发**同一个视频文件**,
* 所以它的特征是「多首不同歌曲的本地文件**内容完全相同**」。按「文件大小 + 头部指纹」
* 分组,只清理 ≥2 个文件完全相同的组 —— 正常歌曲各有各的内容,绝不会被误删。
*
* @return 删除的文件数(已清理过则返回 0)
*/
@JvmStatic
fun purgeInjectedAdDuplicatesOnce(context: android.content.Context): Int {
val marker = File(context.filesDir, "ad_dup_purge_v1")
if (marker.exists()) return 0
var removed = 0
var groups = 0
runCatching {
val files = AppPaths.cloudSongsDir.listFiles().orEmpty()
.filter { it.isFile && it.extension.lowercase() in MEDIA_EXTENSIONS && it.length() > 0 }
// 第一层:按大小分组(同尺寸才会有嫌疑);第二层:比对头部指纹做双重确认。
files.groupBy { it.length() }
.filterValues { it.size >= 2 }
.forEach { (_, sameSize) ->
sameSize.groupBy { headFingerprint(it) }
.forEach { (fingerprint, duplicates) ->
if (fingerprint != null && duplicates.size >= 2) {
groups++
duplicates.forEach { file ->
SongFileValidator.forget(file)
if (file.delete()) removed++
}
}
}
}
marker.writeText("purged=${System.currentTimeMillis()} removed=$removed groups=$groups", Charsets.UTF_8)
}.onFailure { Log.w(TAG, "purge injected ad duplicates failed: ${it.message}") }
if (removed > 0) {
MiliLog.warn(
"SONG-AD 清理注入广告副本 removed=$removed groups=$groups(多首歌共用同一段视频)" +
" 保留正常歌曲=${AppPaths.cloudSongsDir.listFiles()?.size ?: 0}",
)
}
return removed
}
/** 文件头部 4MB 的 MD5,用于"同一段视频"的判定(读全文件太慢,广告同一文件必然头部也相同)。 */
private fun headFingerprint(file: File): String? = runCatching {
val digest = java.security.MessageDigest.getInstance("MD5")
file.inputStream().use { input ->
val buffer = ByteArray(1024 * 1024)
var total = 0
while (total < 4 * 1024 * 1024) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
total += read
}
}
digest.digest().joinToString("") { "%02x".format(it) }
}.getOrNull()
@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 {
// ⚠️ 只有「从文件名推出」的点歌编号才可信,才能参与 `.ts` 文件名比对。
// `song.id` 在曲库条目里可能是数据库主键(非点歌编号),拿它去比对会把**正常地址误杀**
// (表现:真源地址被判"文件名不符"→ 永远下不到歌)。此时传 null = 跳过文件名规则,
// 仅保留广告域名/路径特征那两条判定。
val musicNoFromFile = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls")?.takeIf { it.isNotBlank() }
val musicNo = musicNoFromFile ?: song.id
if (!forceRefresh) {
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
if (AdVideoFilter.isTrustedSongUrl(cached, musicNoFromFile)) return cached
// 缓存的地址不可信(广告 / 文件名与点歌编号不符):除了清地址,还要**删掉已落盘的
// 同名文件** —— 广告视频能通过内容校验(≥6MB / ≥60s / TS 包完整),
// 一旦被缓存成歌曲,isDownloaded() 会直接复用、连取址都不会再发生。
Log.w(TAG, "丢弃不可信缓存地址: ${song.title} -> $cached")
MiliLog.warn("SONG-AD 丢弃不可信缓存地址 编号=$musicNo url=$cached")
song.downloadUrl = null
purgeCachedSongFile(song, "untrusted-cached-url")
}
}
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
val srcHost = SongApiClient.lastSongSourceHost()
// ⚠️ 广告漏放的**最终兜底防线**:不管地址来自 JS 取址、原生兜底还是 DB 回退,
// 都必须过 AdVideoFilter.isTrustedSongUrl(2026-09-26 事故:JS 路径返回
// gz.ac16.vip 的广告视频,因为这里的成功分支完全没做广告校验而被当成歌曲下载播放)。
if (url.isNotEmpty() && AdVideoFilter.isTrustedSongUrl(url, musicNoFromFile)) {
song.downloadUrl = url
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
MiliLog.info(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=成功 urlHost=$cdnHost",
)
} else if (url.isNotEmpty()) {
Log.w(TAG, "Untrusted URL rejected for ${song.title}: $url")
MiliLog.error(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 " +
"详情=地址不可信(广告或文件名不符) url=$url",
)
purgeCachedSongFile(song, "untrusted-resolved-url")
""
} else {
Log.w(TAG, "Unable to get download URL: ${song.title}")
MiliLog.error(
"点歌编号=$musicNo 标题=${song.title} 请求源=$srcHost 状态=失败 详情=无法生成下载地址",
)
""
}
}
}
/** 删除某首歌已落盘的文件(发现地址不可信/命中广告时调用,让下次点歌重新取址下载)。 */
private fun purgeCachedSongFile(song: Song, reason: String) {
runCatching {
val file = getLocalFile(song)
if (file.exists()) {
val sizeMb = file.length() / 1024 / 1024
SongFileValidator.forget(file)
val deleted = file.delete()
MiliLog.warn(
"SONG-AD 清除可疑歌曲缓存 编号=${song.id} 原因=$reason " +
"大小=${sizeMb}MB 已删除=$deleted",
)
}
song.path = null
}.onFailure { Log.w(TAG, "purge cached file failed: ${it.message}") }
}
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()
}