fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления

- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео
- resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null)
- DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job)
- Тесты 62/0 (в т.ч. поколения — поймали null != 0L)

Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
Hermes Agent
2026-08-19 17:19:53 +03:00
parent fafdddc121
commit 49dac8b33f
29 changed files with 2237 additions and 173 deletions
@@ -19,6 +19,7 @@ import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.media.MirrorFiles
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.ConcurrentHashMap
@@ -47,6 +48,10 @@ data class DownloadStatus(
val percent: Int = 0,
val totalBytes: Long = 0,
val updatedAt: Long = 0,
/** Ожидаемый набор дорожек: "video", "audio-0", ... — из зеркала, а не из того, что на диске. */
val expectedFiles: List<String> = emptyList(),
/** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */
val fileSizes: Map<String, Long> = emptyMap(),
)
internal val statusJson: Json = Json {
@@ -82,9 +87,96 @@ internal data class DiskScanResult(
)
/**
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
* из размера .part и totalBytes из статуса.
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
* повторный вызов ничего не меняет (карты уже не содержат itemId).
*/
internal fun removeDownloadState(
statuses: MutableMap<String, DownloadStatus>,
downloads: MutableMap<String, DownloadProgress>,
downloadedIds: MutableSet<String>,
itemId: String,
) {
downloads.remove(itemId)
downloadedIds.remove(itemId)
statuses.remove(itemId)
}
/**
* Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение —
* старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку.
* Нет записи — поколение 0 (никогда не удалялось).
*/
internal fun isCurrentGeneration(generations: Map<String, Long>, itemId: String, generation: Long): Boolean =
(generations[itemId] ?: 0L) == generation
/** Имя финального файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
internal fun finalFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
/** Имя .part-файла для ключа дорожки. */
internal fun partFileName(key: String): String = "${finalFileName(key)}.part"
/** Ключ дорожки по имени .part-файла ("video.mkv.part" → "video", "audio-0.ogg.part" → "audio-0"). */
internal fun fileKeyFor(partName: String): String {
val base = partName.removeSuffix(".part")
return if (base == "video.mkv") "video" else base.removeSuffix(".ogg")
}
/** Список ключей дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
internal fun fileKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
if (keys.isEmpty()) return false
return keys.all { key ->
val final = File(dir, finalFileName(key))
final.isFile && final.length() > 0 && !File(dir, partFileName(key)).exists()
}
}
/** Общий процент скачанного на диск по .part/финальным файлам относительно [sizes]. */
internal fun diskPercent(dir: File, keys: List<String>, sizes: Map<String, Long>): Int {
if (keys.isEmpty()) return 0
val totalSum = keys.sumOf { sizes[it] ?: 0L }
if (totalSum <= 0) return 0
var done = 0L
keys.forEach { key ->
val size = sizes[key] ?: 0L
val part = File(dir, partFileName(key))
val final = File(dir, finalFileName(key))
done += when {
part.isFile && part.length() > 0 -> if (size > 0) part.length().coerceAtMost(size) else 0L
final.isFile && final.length() > 0 -> if (size > 0) size else final.length()
else -> 0L
}
}
return ((done * 100) / totalSum).toInt().coerceIn(0, 100)
}
/** Процент одного файла [fileName] по .part/финальному файлу относительно его размера; null, если посчитать нельзя. */
internal fun filePercent(dir: File, fileName: String, sizes: Map<String, Long>): Int? {
val final = File(dir, fileName)
if (final.isFile && final.length() > 0) return 100
val part = File(dir, "$fileName.part")
if (part.isFile && part.length() > 0) {
val total = sizes[fileKeyFor(part.name)] ?: 0L
if (total > 0) return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
}
return null
}
/** Общий процент по живым суммам байтов (скачано/всего по всем файлам). */
internal fun combinedPercent(totals: Map<String, Long>, done: Map<String, Long>): Int {
val totalSum = totals.values.sum()
if (totalSum <= 0) return 0
val doneSum = done.values.sum()
return ((doneSum * 100) / totalSum).toInt().coerceIn(0, 100)
}
/**
* Скан папок filesDir/viewmate/<itemId>: все ожидаемые файлы → itemId в downloaded
* (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом
* по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска.
*/
internal fun scanDownloads(
baseDir: File,
@@ -96,17 +188,23 @@ internal fun scanDownloads(
baseDir.listFiles()?.forEach { dir ->
if (!dir.isDirectory) return@forEach
val itemId = dir.name
val savedStatus = saved[itemId]
val keys = savedStatus?.expectedFiles.orEmpty()
val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty()
val biggestPart = parts.maxByOrNull { it.length() }
val hasFinal = File(dir, "video.mkv").exists()
if (hasFinal) {
// старый формат (без expectedFiles): полным считаем наличие video.mkv
val fully = if (keys.isNotEmpty()) isFullyOnDisk(dir, keys) else File(dir, "video.mkv").isFile
if (fully) {
downloaded += itemId
parts.forEach { it.delete() }
} else if (biggestPart != null && biggestPart.length() > 0) {
val savedStatus = saved[itemId]
val total = savedStatus?.totalBytes ?: 0L
val percent = if (total > 0) ((biggestPart.length() * 100) / total).toInt().coerceIn(0, 99) else 0
} else if (parts.any { it.length() > 0 }) {
val percent = if (keys.isNotEmpty()) {
diskPercent(dir, keys, savedStatus?.fileSizes.orEmpty())
} else {
val total = savedStatus?.totalBytes ?: 0L
val biggest = parts.maxByOrNull { it.length() } ?: return@forEach
if (total > 0) ((biggest.length() * 100) / total).toInt().coerceIn(0, 99) else 0
}
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
}
@@ -123,7 +221,7 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
}
/**
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
* при старте диск сканируется, докачка — только по явному тапу пользователя.
@@ -135,6 +233,13 @@ class DownloadManager(
private val baseDir = File(context.filesDir, "viewmate")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val jobs = ConcurrentHashMap<String, Job>()
/**
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
*/
private val generations = ConcurrentHashMap<String, Long>()
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
private val statusStore = DownloadStatusStore(File(baseDir, "status.json"))
private val okHttp = OkHttpClient.Builder()
@@ -180,6 +285,8 @@ class DownloadManager(
percent = p.percent,
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
)
}
statusStore.write(statuses)
@@ -213,74 +320,122 @@ class DownloadManager(
/** Видео уже скачано на телефон. */
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
/** Процент неполного файла [part] от полного размера (totalBytes из статуса). */
fun partPercent(itemId: String, part: File): Int {
if (!part.exists() || part.length() <= 0) return 0
val total = statuses[itemId]?.totalBytes ?: 0L
if (total <= 0) return 0
return ((part.length() * 100) / total).toInt().coerceIn(0, 99)
/**
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
*/
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
val keys = files?.let { fileKeys(it) } ?: return false
if (keys.isEmpty()) return false
return isFullyOnDisk(dirFor(itemId), keys)
}
/**
* Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
*/
fun startDownload(itemId: String, audioIndex: Int) {
if (jobs[itemId]?.isActive == true) return
if (isDownloaded(itemId)) return
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
fun overallPercent(itemId: String): Int {
val st = statuses[itemId] ?: return 0
if (st.expectedFiles.isEmpty()) return 0
return diskPercent(dirFor(itemId), st.expectedFiles, st.fileSizes)
}
jobs[itemId] = scope.launch {
/** Известные размеры дорожек (key → байт) после скачивания на телефон; пусто, если качки не было. */
fun knownFileSizes(itemId: String): Map<String, Long> =
statuses[itemId]?.fileSizes.orEmpty()
/** Процент конкретного файла [fileName] ("video.mkv", "audio-0.ogg") на диске телефона; null, если посчитать нельзя. */
fun percentForFile(itemId: String, fileName: String): Int? {
val st = statuses[itemId]
return filePercent(dirFor(itemId), fileName, st?.fileSizes.orEmpty())
}
/** Процент неполного файла [part] от полного размера дорожки (fileSizes/totalBytes из статуса). */
fun partPercent(itemId: String, part: File): Int =
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
/**
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
*/
fun startDownload(itemId: String) {
if (jobs[itemId]?.isActive == true) return
val generation = generations[itemId] ?: 0L
lateinit var job: Job
job = scope.launch {
_activeIds.value = _activeIds.value + itemId
try {
// корутина «устарела»: itemId удалили (delete) — дальше не идём,
// чтобы не создать заново каталог/статус/.part после удаления
fun stale() = !isCurrentGeneration(generations, itemId, generation)
if (stale()) return@launch
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало"))
val status = mirror.mirrorByItem(itemId)
if (stale()) return@launch
val files = status?.files
if (status == null || status.status != "done" || files == null) {
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
return@launch
}
val keys = fileKeys(files)
val dir = dirFor(itemId)
if (stale()) return@launch
dir.mkdirs()
downloadFile(
url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = partVideo(itemId),
dest = videoFile(itemId),
onProgress = { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео"))
},
onTotal = { total -> setTotalBytes(itemId, total) },
)
if (isFullyOnDisk(dir, keys)) {
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "$itemId уже полностью скачан на диск")
return@launch
}
setExpectedFiles(itemId, keys)
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
if (audio != null) {
val totals = HashMap<String, Long>()
val done = HashMap<String, Long>()
keys.forEach { key ->
if (stale()) return@launch
val url = if (key == "video") files.video.url
else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
val stage = if (key == "video") "видео" else "звук"
downloadFile(
url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = partAudio(itemId, audio.index),
dest = audioFile(itemId, audio.index),
onProgress = { percent ->
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук"))
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
part = File(dir, partFileName(key)),
dest = File(dir, finalFileName(key)),
onTotal = { total ->
totals[key] = total
setFileSize(itemId, key, total)
},
onProgress = { d, t ->
totals[key] = t
done[key] = d
update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, combinedPercent(totals, done), stage))
},
onTotal = { total -> setTotalBytes(itemId, total) },
)
if (stale()) return@launch
done[key] = totals[key] ?: 0L
}
if (stale()) return@launch
update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово"))
_downloadedIds.value = _downloadedIds.value + itemId
log("download", "скачано $itemId → $dir")
} catch (e: kotlinx.coroutines.CancellationException) {
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
if (isCurrentGeneration(generations, itemId, generation)) {
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
}
throw e
} catch (e: Exception) {
log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
if (isCurrentGeneration(generations, itemId, generation)) {
log("download", "ошибка скачивания $itemId: ${e.message}")
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
}
} finally {
jobs.remove(itemId)
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
jobs.remove(itemId, job)
_activeIds.value = _activeIds.value - itemId
}
}
jobs[itemId] = job
}
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
@@ -288,32 +443,34 @@ class DownloadManager(
jobs.remove(itemId)?.cancel()
}
/** Удалить скачанные файлы с телефона и сбросить статус. */
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
fun delete(itemId: String) {
cancel(itemId)
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
generations[itemId] = (generations[itemId] ?: 0L) + 1
dirFor(itemId).deleteRecursively()
_downloads.value = _downloads.value - itemId
_downloadedIds.value = _downloadedIds.value - itemId
statuses.remove(itemId)
val downloads = _downloads.value.toMutableMap()
val downloadedIds = _downloadedIds.value.toMutableSet()
removeDownloadState(statuses, downloads, downloadedIds, itemId)
_downloads.value = downloads
_downloadedIds.value = downloadedIds
_activeIds.value = _activeIds.value - itemId
persistStatus(force = true)
log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})")
}
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */
private fun resumePercent(itemId: String): Int {
val total = statuses[itemId]?.totalBytes ?: 0L
if (total <= 0) return 0
return partPercent(itemId, partVideo(itemId))
}
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
/**
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
* по завершении — rename в [dest] (без .part).
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
*/
private suspend fun downloadFile(
url: String,
part: File,
dest: File,
onProgress: (Int) -> Unit,
onProgress: (done: Long, total: Long) -> Unit,
onTotal: (Long) -> Unit,
) = withContext(Dispatchers.IO) {
val existing = if (part.exists()) part.length() else 0L
@@ -334,6 +491,8 @@ class DownloadManager(
onTotal(total)
val input = body.byteStream()
// перед созданием/открытием .part — стоп, если качку отменили/удалили
currentCoroutineContext().ensureActive()
FileOutputStream(part, resumed).use { out ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var done = from
@@ -343,13 +502,13 @@ class DownloadManager(
if (read < 0) break
out.write(buffer, 0, read)
done += read
if (total > 0) {
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
onProgress(percent)
}
if (total > 0) onProgress(done, total)
}
}
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
// иначе удаление «воскреснет» переименованным .part
currentCoroutineContext().ensureActive()
if (dest.exists()) dest.delete()
if (!part.renameTo(dest)) {
part.copyTo(dest, overwrite = true)
@@ -358,22 +517,49 @@ class DownloadManager(
}
}
private fun setTotalBytes(itemId: String, total: Long) {
if (total <= 0) return
private fun setFileSize(itemId: String, key: String, size: Long) {
if (size <= 0) return
val prev = statuses[itemId]
if (prev != null && prev.totalBytes > 0) return
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
val sizes = prev?.fileSizes ?: emptyMap()
if (sizes.containsKey(key)) return
statuses[itemId] = DownloadStatus(
phase = prev?.phase ?: "downloading",
stage = prev?.stage ?: "",
percent = prev?.percent ?: 0,
totalBytes = (prev?.totalBytes ?: 0L) + size,
updatedAt = System.currentTimeMillis(),
expectedFiles = prev?.expectedFiles ?: emptyList(),
fileSizes = sizes + (key to size),
)
persistStatus()
}
private fun setExpectedFiles(itemId: String, keys: List<String>) {
val prev = statuses[itemId]
if (prev?.expectedFiles == keys) return
statuses[itemId] = DownloadStatus(
phase = prev?.phase ?: "downloading",
stage = prev?.stage ?: "",
percent = prev?.percent ?: 0,
totalBytes = prev?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = keys,
fileSizes = prev?.fileSizes ?: emptyMap(),
)
persistStatus()
}
private fun update(itemId: String, progress: DownloadProgress) {
_downloads.value = _downloads.value + (itemId to progress)
val prev = statuses[itemId]
statuses[itemId] = DownloadStatus(
phase = progress.phase.name.lowercase(),
stage = progress.stage,
percent = progress.percent,
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
totalBytes = prev?.totalBytes ?: 0L,
updatedAt = System.currentTimeMillis(),
expectedFiles = prev?.expectedFiles ?: emptyList(),
fileSizes = prev?.fileSizes ?: emptyMap(),
)
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
}