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,8 +1,14 @@
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 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 AudioSyncPlayerTest {
@@ -59,4 +65,70 @@ class AudioSyncPlayerTest {
assertNull(d.seekToMs)
assertNull(d.playing)
}
// --- resolveAudioSourceDetailed (чистая функция, ветки причины «нет звука») ---
private fun mirrorFiles(audios: List<MirrorAudio> = listOf()) = MirrorFiles(
video = MirrorVideo(key = "v", url = "https://s3/mirror/video.mkv"),
audios = audios,
)
private fun audioDir(): File = Files.createTempDirectory("audio-src-test").toFile()
@Test
fun sourceIsNullWhenMirrorFilesMissing() {
val result = resolveAudioSourceDetailed(null, 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
assertNull(result.source)
assertTrue(result.reason.contains("null"), "причина должна указывать на отсутствующее зеркало: ${result.reason}")
}
@Test
fun sourceIsNullWhenAudiosEmpty() {
val result = resolveAudioSourceDetailed(mirrorFiles(), 0, { File("/x", "audio-$it.ogg") }) { "signed:$it" }
assertNull(result.source)
assertTrue(result.reason.contains("audios пусто"), "причина должна указывать на пустой список дорожек: ${result.reason}")
}
@Test
fun usesRequestedAudioIndex() {
val dir = audioDir()
File(dir, "audio-1.ogg").writeBytes(ByteArray(5))
val audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
MirrorAudio(index = 1, key = "a1", url = "https://s3/audio-1.ogg"),
)
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 1, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals(File(dir, "audio-1.ogg").absolutePath, result.source)
assertTrue(result.reason.contains("локальный файл"), "выбрана именно дорожка #1")
}
@Test
fun fallsBackToFirstAudioWhenIndexMissing() {
val dir = audioDir()
File(dir, "audio-0.ogg").writeBytes(ByteArray(5))
val audios = listOf(
MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"),
MirrorAudio(index = 2, key = "a2", url = "https://s3/audio-2.ogg"),
)
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 7, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals(File(dir, "audio-0.ogg").absolutePath, result.source, "нет #7 — берём первую дорожку")
}
@Test
fun usesLocalFileWhenDownloaded() {
val dir = audioDir()
val local = File(dir, "audio-3.ogg").apply { writeBytes(ByteArray(10)) }
val audios = listOf(MirrorAudio(index = 3, key = "a3", url = "https://s3/audio-3.ogg"))
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 3, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals(local.absolutePath, result.source)
}
@Test
fun streamsPresignedUrlWhenNoLocalFile() {
val dir = audioDir()
val audios = listOf(MirrorAudio(index = 0, key = "a0", url = "https://s3/audio-0.ogg"))
val result = resolveAudioSourceDetailed(mirrorFiles(audios), 0, { File(dir, "audio-$it.ogg") }) { "signed:$it" }
assertEquals("signed:https://s3/audio-0.ogg", result.source)
assertTrue(result.reason.contains("стриминг"), "без локального файла — стриминг: ${result.reason}")
}
}