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() // ConcurrentHashMap.newKeySet() 是 API 24+(低版本 NoSuchMethodError); // Collections.newSetFromMap 自 API 9 起可用,且返回普通 Set,add/remove/contains 均成立于 API 1。 private val pending: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) private val callbacks = ConcurrentHashMap() private val progress = ConcurrentHashMap() private val totalLengths = ConcurrentHashMap() private val forcedRetryReasons = ConcurrentHashMap() private val manuallyCanceled: MutableSet = Collections.newSetFromMap(ConcurrentHashMap()) 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>, ) { 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()) private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty) ?: song.filename?.takeIf(String::isNotEmpty) ?: song.title.orEmpty() }