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
@@ -0,0 +1,168 @@
package pw.binom.viewmate.phone
import pw.binom.viewmate.core.media.MirrorAudio
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.media.MirrorVideo
import pw.binom.viewmate.core.phone.buildDownloadFiles
import pw.binom.viewmate.core.phone.buildGlassesFileSpecs
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress
import java.io.File
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class GlassesFilesTest {
private fun tempDir(): File = Files.createTempDirectory("glasses-files-test").toFile()
private fun mirrorFiles() = MirrorFiles(
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
audios = listOf(
MirrorAudio(index = 0, key = "a0", title = "Русский", url = "https://s3/mirror/audio-0.ogg"),
MirrorAudio(index = 1, key = "a1", url = "https://s3/mirror/audio-1.ogg"),
),
)
@Test
fun buildDownloadFilesUsesPresignedUrlsAndNames() {
val msg = buildDownloadFiles(
itemId = "movie-1",
title = "Interstellar",
files = mirrorFiles(),
sizes = emptyMap(),
presign = { "$it?sig" },
)
assertEquals("movie-1", msg.itemId)
assertEquals("Interstellar", msg.title)
assertEquals(3, msg.files.size)
assertEquals("video.mkv", msg.files[0].name)
assertEquals("https://s3/mirror/video.mkv?sig", msg.files[0].url)
assertEquals("audio-0.ogg", msg.files[1].name)
assertEquals("audio-1.ogg", msg.files[2].name)
}
@Test
fun buildDownloadFilesFillsKnownSizes() {
val msg = buildDownloadFiles(
itemId = "movie-1",
title = "T",
files = mirrorFiles(),
sizes = mapOf("video" to 1_863_946_240L, "audio-0" to 68_157_440L),
presign = { it },
)
assertEquals(1_863_946_240L, msg.files[0].sizeBytes)
assertEquals(68_157_440L, msg.files[1].sizeBytes)
assertEquals(0L, msg.files[2].sizeBytes)
}
@Test
fun buildGlassesFileSpecsPresignsEveryFile() {
val specs = buildGlassesFileSpecs(
files = mirrorFiles(),
sizes = emptyMap(),
presign = { "signed-$it" },
)
assertEquals(3, specs.size)
assertTrue(specs.all { it.url.startsWith("signed-") })
}
@Test
fun glassesFileStateNullIsNone() {
assertEquals(FileDownloadState.NONE to 0, glassesFileState(null))
}
@Test
fun glassesFileStateMapsPhases() {
val done = DownloadProgress("i", "video.mkv", 100, 10, 10, DownloadPhase.DONE)
assertEquals(FileDownloadState.DONE to 100, glassesFileState(done))
val downloading = DownloadProgress("i", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)
assertEquals(FileDownloadState.DOWNLOADING to 45, glassesFileState(downloading))
val failed = DownloadProgress("i", "audio-0.ogg", 0, 5, 10, DownloadPhase.FAILED)
assertEquals(FileDownloadState.FAILED to 0, glassesFileState(failed))
}
@Test
fun glassesFileStateClampsDownloadingPercent() {
val p = DownloadProgress("i", "video.mkv", 150, 150, 100, DownloadPhase.DOWNLOADING)
assertEquals(FileDownloadState.DOWNLOADING to 99, glassesFileState(p))
}
@Test
fun phoneFileStateFinalExistsIsDone() {
val dir = tempDir()
val final = File(dir, "video.mkv").apply { writeBytes(ByteArray(10)) }
val part = File(dir, "video.mkv.part")
assertEquals(FileDownloadState.DONE to 100, phoneFileState(final, part))
}
@Test
fun phoneFileStatePartIsDownloading() {
val dir = tempDir()
val final = File(dir, "video.mkv")
val part = File(dir, "video.mkv.part").apply { writeBytes(ByteArray(10)) }
assertEquals(FileDownloadState.DOWNLOADING to 0, phoneFileState(final, part))
}
@Test
fun phoneFileStateNoneWhenNothing() {
val dir = tempDir()
assertEquals(FileDownloadState.NONE to 0, phoneFileState(File(dir, "video.mkv"), File(dir, "video.mkv.part")))
}
@Test
fun glassesOverallPercentEmptyIsNull() {
assertNull(glassesOverallPercent(listOf("video.mkv", "audio-0.ogg"), emptyMap(), emptyMap()))
}
@Test
fun glassesOverallPercentAllDoneIsHundred() {
val names = listOf("video.mkv", "audio-0.ogg")
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
"audio-0.ogg" to DownloadProgress("i", "audio-0.ogg", 100, 50, 50, DownloadPhase.DONE),
)
assertEquals(100, glassesOverallPercent(names, emptyMap(), perFile))
}
@Test
fun glassesOverallPercentUsesKnownSizes() {
val names = listOf("video.mkv", "audio-0.ogg")
val known = mapOf("video.mkv" to 100L, "audio-0.ogg" to 100L)
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
"audio-0.ogg" to DownloadProgress("i", "audio-0.ogg", 25, 25, 100, DownloadPhase.DOWNLOADING),
)
assertEquals(62, glassesOverallPercent(names, known, perFile)) // 125/200
}
@Test
fun glassesOverallPercentFallsBackToStartedTotals() {
val names = listOf("video.mkv", "audio-0.ogg")
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 50, 50, 100, DownloadPhase.DOWNLOADING),
)
assertEquals(50, glassesOverallPercent(names, emptyMap(), perFile))
}
@Test
fun glassesLivePercentRejectsEmpty() {
assertNull(glassesLivePercent(emptyMap()))
}
@Test
fun glassesLivePercentAllDoneIsHundred() {
val perFile = mapOf(
"video.mkv" to DownloadProgress("i", "video.mkv", 100, 100, 100, DownloadPhase.DONE),
)
assertEquals(100, glassesLivePercent(perFile))
}
}