现象: 新设备公放时按「切歌」没有任何反应。 根因(我上一版引入的回归): - 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 例全通过。
546 lines
25 KiB
Kotlin
546 lines
25 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
|
||
|
||
/** 曲库视频目录下会被当作"已下载歌曲"的文件后缀(用于一次性清理历史广告缓存)。 */
|
||
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()
|
||
|
||
}
|