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
@@ -1,6 +1,9 @@
package pw.binom.viewmate.phone
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.media.MirrorAudio
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.media.MirrorVideo
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
@@ -115,4 +118,183 @@ class DownloadManagerTest {
assertTrue(result.downloaded.isEmpty())
assertTrue(result.progress.isEmpty())
}
@Test
fun fileKeysFromMirrorCoversVideoAndAllAudios() {
val files = MirrorFiles(
video = MirrorVideo(key = "v", url = "http://v"),
audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "http://a0"),
MirrorAudio(index = 1, key = "a1", url = "http://a1"),
),
)
assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files))
}
@Test
fun isFullyOnDiskRequiresAllFilesNoParts() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
val keys = listOf("video", "audio-0", "audio-1")
assertTrue(!isFullyOnDisk(dir, keys), "пустой каталог — не полный")
File(dir, "video.mkv").writeBytes(ByteArray(10))
assertTrue(!isFullyOnDisk(dir, keys), "только видео — не полный")
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
assertTrue(!isFullyOnDisk(dir, keys), "не все аудио — не полный")
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
assertTrue(isFullyOnDisk(dir, keys), "все файлы — полный")
}
@Test
fun isFullyOnDiskRejectsPartFiles() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(10))
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(2))
assertTrue(!isFullyOnDisk(dir, listOf("video", "audio-0", "audio-1")), ".part не даёт полного")
}
@Test
fun combinedPercentAveragesAcrossAllFiles() {
val totals = mapOf("video" to 100L, "audio-0" to 50L, "audio-1" to 50L)
val done = mapOf("video" to 100L, "audio-0" to 25L, "audio-1" to 0L)
assertEquals(62, combinedPercent(totals, done)) // 125/200
}
@Test
fun diskPercentCountsPartsAndFinalFiles() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(100)) // видео полностью
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50)) // аудио докачивается
val sizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L)
assertEquals(50, diskPercent(dir, listOf("video", "audio-0", "audio-1"), sizes)) // 150/300
}
@Test
fun filePercentFromPartAndFinal() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
// ничего нет — процент посчитать нельзя
assertNull(filePercent(dir, "video.mkv", emptyMap()))
// .part: процент от известного размера дорожки
File(dir, "video.mkv.part").writeBytes(ByteArray(50))
assertEquals(50, filePercent(dir, "video.mkv", mapOf("video" to 100L)))
assertNull(filePercent(dir, "video.mkv", emptyMap()), "без размера дорожки процент не считаем")
// полный файл — 100% (даже если размера в статусе нет)
File(dir, "video.mkv").writeBytes(ByteArray(100))
assertEquals(100, filePercent(dir, "video.mkv", emptyMap()))
// аудио не начато
assertNull(filePercent(dir, "audio-0.ogg", mapOf("audio-0" to 100L)))
}
@Test
fun scanWithExpectedFilesMarksPartialWithOverallPercent() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(100))
File(dir, "audio-0.ogg.part").writeBytes(ByteArray(50))
val saved = mapOf(
"i" to DownloadStatus(
phase = "downloading",
expectedFiles = listOf("video", "audio-0", "audio-1"),
fileSizes = mapOf("video" to 100L, "audio-0" to 100L, "audio-1" to 100L),
),
)
val result = scanDownloads(base, saved)
assertTrue(result.downloaded.isEmpty())
assertEquals(DownloadPhase.DOWNLOADING, result.progress["i"]?.phase)
assertEquals(50, result.progress["i"]?.percent)
}
@Test
fun scanWithExpectedFilesMarksFullyDownloadedOnlyWhenAllFiles() {
val base = newBaseDir()
val dir = File(base, "i").apply { mkdirs() }
File(dir, "video.mkv").writeBytes(ByteArray(100))
File(dir, "audio-0.ogg").writeBytes(ByteArray(50))
val saved = mapOf(
"i" to DownloadStatus(expectedFiles = listOf("video", "audio-0", "audio-1")),
)
// нет audio-1 — не полный
assertTrue(scanDownloads(base, saved).downloaded.isEmpty())
File(dir, "audio-1.ogg").writeBytes(ByteArray(50))
val result = scanDownloads(base, saved)
assertEquals(setOf("i"), result.downloaded)
assertTrue(result.progress.isEmpty())
}
@Test
fun fileKeyForMapsPartNamesToTrackKeys() {
assertEquals("video", fileKeyFor("video.mkv.part"))
assertEquals("audio-0", fileKeyFor("audio-0.ogg.part"))
assertEquals("audio-1", fileKeyFor("audio-1.ogg.part"))
assertEquals("video.mkv.part", partFileName("video"))
assertEquals("audio-1.ogg.part", partFileName("audio-1"))
assertEquals("video.mkv", finalFileName("video"))
assertEquals("audio-0.ogg", finalFileName("audio-0"))
}
@Test
fun removeDownloadStateIsIdempotent() {
val statuses = mutableMapOf(
"i1" to DownloadStatus(phase = "downloading", percent = 40),
"i2" to DownloadStatus(phase = "done", percent = 100),
)
val downloads = mutableMapOf(
"i1" to DownloadProgress("i1", DownloadPhase.DOWNLOADING, 40, "видео"),
"i2" to DownloadProgress("i2", DownloadPhase.DONE, 100, "готово"),
)
val downloadedIds = mutableSetOf("i1", "i2")
removeDownloadState(statuses, downloads, downloadedIds, "i1")
assertTrue("i1" !in statuses, "статус удалённого itemId не должен оставаться")
assertTrue("i1" !in downloads, "прогресс удалённого itemId не должен оставаться")
assertTrue("i1" !in downloadedIds)
// чужие элементы не трогаем
assertEquals("done", statuses["i2"]?.phase)
assertTrue("i2" in downloads)
// повторный вызов (и даже третий) — идемпотентен, ничего не меняет
val snapshot = Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet())
removeDownloadState(statuses, downloads, downloadedIds, "i1")
removeDownloadState(statuses, downloads, downloadedIds, "i1")
assertEquals(snapshot, Triple(statuses.toMap(), downloads.toMap(), downloadedIds.toSet()))
}
@Test
fun deleteBumpsGenerationInvalidatingRunningDownload() {
val generations = mutableMapOf("i1" to 0L)
val running = 0L
assertTrue(isCurrentGeneration(generations, "i1", running), "до удаления качка актуальна")
assertTrue(isCurrentGeneration(generations, "i2", 0L), "нет записи — поколение 0 актуально")
// delete() увеличивает поколение
generations["i1"] = (generations["i1"] ?: 0L) + 1
assertTrue(
!isCurrentGeneration(generations, "i1", running),
"старая незавершённая корутина после delete должна «умереть», а не воскресить качку",
)
assertTrue(isCurrentGeneration(generations, "i1", 1L), "новая качка после delete актуальна")
assertTrue(isCurrentGeneration(generations, "i2", 0L), "другие itemId не затронуты")
}
}