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,47 @@
package pw.binom.viewmate.core.phone
import pw.binom.viewmate.core.media.MirrorFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.FileSpec
/** Имя файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */
fun trackFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg"
/** Ключи дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */
fun trackKeys(files: MirrorFiles): List<String> =
listOf("video") + files.audios.map { "audio-${it.index}" }
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */
fun buildGlassesFileSpecs(
files: MirrorFiles,
sizes: Map<String, Long>,
presign: (String) -> String,
): List<FileSpec> {
val video = FileSpec(
url = presign(files.video.url),
name = trackFileName("video"),
sizeBytes = sizes["video"] ?: 0L,
)
val audios = files.audios.map { a ->
val key = "audio-${a.index}"
FileSpec(
url = presign(a.url),
name = trackFileName(key),
sizeBytes = sizes[key] ?: 0L,
)
}
return listOf(video) + audios
}
/** Полное сообщение DownloadFiles для очков. */
fun buildDownloadFiles(
itemId: String,
title: String,
files: MirrorFiles,
sizes: Map<String, Long>,
presign: (String) -> String,
): DownloadFiles = DownloadFiles(
itemId = itemId,
title = title,
files = buildGlassesFileSpecs(files, sizes, presign),
)
@@ -11,6 +11,8 @@ import pw.binom.viewmate.core.media.MirrorStatus
import pw.binom.viewmate.core.protocol.AssistantState
import pw.binom.viewmate.core.protocol.AssistantStateMsg
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.MediaCommandMsg
import pw.binom.viewmate.core.protocol.PlayVideo
import pw.binom.viewmate.core.protocol.SetMode
@@ -83,25 +85,58 @@ class PhoneActions(
// --- включить на очках ---
/** Включить на очках: зеркало должно быть done → PlayVideo с audioIndex. */
suspend fun watch(itemId: String, audioIndex: Int = 0, presign: (String) -> String = { it }): String {
/**
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
*/
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = ""): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"}). Закажите: mirror $itemId"
}
if (!localVideoPath.startsWith("file://")) {
return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»"
}
val title = item.Name ?: itemId
val videoUrl = presign(files.video.url)
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = videoUrl, audioIndex = audioIndex))
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
state.mode = GlassesMode.MOVIE
state.currentItem = item
state.currentAudioIndex = audioIndex
state.mirrorStatus = status
state.playing = true
state.downloaded[itemId] = "очки"
return "включил «$title» на очках (озвучка #$audioIndex): $videoUrl"
return "включил «$title» на очках из локального файла (озвучка #$audioIndex)"
}
/**
* Скачать контент в память очков: DownloadFiles с presigned URL всех файлов
* (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт).
*/
suspend fun downloadToGlasses(
itemId: String,
sizes: Map<String, Long> = emptyMap(),
presign: (String) -> String = { it },
): String {
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
val status = mirror.mirrorByItem(itemId)
val files = status?.files
if (status == null || status.status != "done" || files == null) {
return "зеркало для «${item.Name ?: itemId}» не готово (${status?.status ?: "нет"})"
}
val title = item.Name ?: itemId
val msg = buildDownloadFiles(
itemId = itemId,
title = title,
files = files,
sizes = sizes,
presign = presign,
)
sender.send(msg)
log("phone", "скачивание на очки: $itemId (${msg.files.size} файлов)")
return "загрузка «$title» на очки"
}
// --- зеркала ---
@@ -146,11 +181,12 @@ class PhoneActions(
return "скачано $itemId → $target (эмуляция, отмечено в стейте)"
}
/** Удалить скачанное (эмуляция). */
fun deleteDownloaded(itemId: String): String {
/** Удалить скачанное: DeleteFiles на очки (каталог контента) + снять эмуляционную пометку. */
suspend fun deleteDownloaded(itemId: String): String {
sender.send(DeleteFiles(itemId))
val removed = state.downloaded.remove(itemId)
return if (removed != null) "удалено скачанное: $itemId ($removed)"
else "не было скачано: $itemId"
else "не было скачано (эмуляция): $itemId"
}
// --- сессии ---
@@ -33,11 +33,34 @@ data class MediaCommandMsg(val command: MediaCommand, val seekMs: Long = 0) : Ho
data class PlayVideo(
val itemId: String,
val title: String,
/** ЛОКАЛЬНЫЙ путь на очках (file:///...) ИЛИ пустой. Очки не стримят: http(s)-URL запрещены. */
val videoUrl: String,
val audioIndex: Int = 0,
val startPositionMs: Long = 0,
) : HostToGlasses
/** Файл контента для скачивания на очки. */
@Serializable
data class FileSpec(
val url: String,
val name: String,
val sizeBytes: Long = 0,
)
/** Скачать контент в память очков: каталог filesDir/viewmate/{itemId}/, файлы — по [FileSpec.name]. */
@Serializable
@SerialName("download_files")
data class DownloadFiles(
val itemId: String,
val title: String,
val files: List<FileSpec>,
) : HostToGlasses
/** Удалить каталог контента на очках. */
@Serializable
@SerialName("delete_files")
data class DeleteFiles(val itemId: String) : HostToGlasses
/** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */
@Serializable
@SerialName("assistant_state")
@@ -22,6 +22,39 @@ data class GlassesStatus(
val batteryPercent: Int,
val storageUsedGb: Double,
val storageTotalGb: Double,
/** Полный объём встроенной памяти, байты. */
val storageTotalBytes: Long = 0,
/** Свободно байт. */
val storageFreeBytes: Long = 0,
/** Сумма скачанного медиа (filesDir/viewmate), байты. */
val mediaBytes: Long = 0,
/** itemId полностью скачанных на очки контентов. */
val downloadedItemIds: List<String> = emptyList(),
/** Каталог контента на очках (filesDir/viewmate) — для локального пути file:// в PlayVideo. */
val mediaDir: String = "",
/** Версия приложения очков (для экрана информации). */
val appVersion: String = "",
) : GlassesToHost
/** Фаза скачивания на очки. */
@Serializable
enum class DownloadPhase {
DOWNLOADING,
DONE,
FAILED,
}
/** Прогресс скачивания одного файла на очки (очки → телефон). */
@Serializable
@SerialName("download_progress")
data class DownloadProgress(
val itemId: String,
/** Имя файла ("video.mkv", "audio-0.ogg") — по нему телефон размечает строки файлов. */
val fileName: String,
val percent: Int,
val doneBytes: Long,
val totalBytes: Long,
val phase: DownloadPhase,
) : GlassesToHost
/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */
@@ -19,6 +19,8 @@ import pw.binom.viewmate.core.media.CreateMirrorRequest
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.protocol.ChatSessionMsg
import pw.binom.viewmate.core.protocol.DeleteFiles
import pw.binom.viewmate.core.protocol.DownloadFiles
import pw.binom.viewmate.core.protocol.HostToGlasses
import pw.binom.viewmate.core.protocol.PlayVideo
import kotlin.test.Test
@@ -156,9 +158,10 @@ class PhoneActionsTest {
}
@Test
fun watchSendsPlayVideoWithUrlAndAudioIndex() = runTest {
fun watchSendsPlayVideoWithLocalPathAndAudioIndex() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1", audioIndex = 1)
val out = actions(simpleJellyfin(), doneMirror(), sender)
.watch("m1", audioIndex = 1, localVideoPath = "file:///data/media/viewmate/m1/video.mkv")
assertTrue(out.contains("включил «Интерстеллар»"))
assertEquals(1, sender.sent.size)
@@ -166,7 +169,7 @@ class PhoneActionsTest {
assertIs<PlayVideo>(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals("https://s3/media/m1/video.mp4", msg.videoUrl)
assertEquals("file:///data/media/viewmate/m1/video.mkv", msg.videoUrl)
assertEquals(1, msg.audioIndex)
assertEquals("m1", sender.phoneState.currentItem?.Id)
@@ -175,13 +178,22 @@ class PhoneActionsTest {
assertEquals("done", sender.phoneState.mirrorStatus?.status)
}
@Test
fun watchWithoutLocalPathIsRejected() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender).watch("m1")
assertTrue(out.contains("не скачан на очки"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
val sender = SenderStub()
val mirrorClient = mirror { request ->
respond("", HttpStatusCode.NotFound)
}
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1")
val out = actions(simpleJellyfin(), mirrorClient, sender).watch("m1", localVideoPath = "file:///x/m1/video.mkv")
assertTrue(out.contains("не готово"))
assertTrue(sender.sent.isEmpty())
@@ -272,7 +284,35 @@ class PhoneActionsTest {
}
@Test
fun downloadAndDeleteDownloadedEmulate() {
fun downloadToGlassesBuildsAndSendsDownloadFiles() = runTest {
val sender = SenderStub()
val out = actions(simpleJellyfin(), doneMirror(), sender)
.downloadToGlasses("m1", sizes = mapOf("video" to 100L)) { "signed-$it" }
assertTrue(out.contains("загрузка"))
assertEquals(1, sender.sent.size)
val msg = sender.sent.single()
assertIs<DownloadFiles>(msg)
assertEquals("m1", msg.itemId)
assertEquals("Интерстеллар", msg.title)
assertEquals(listOf("video.mkv", "audio-1.ogg", "audio-2.ogg"), msg.files.map { it.name })
assertEquals("signed-https://s3/media/m1/video.mp4", msg.files[0].url)
assertEquals(100L, msg.files[0].sizeBytes)
assertTrue(msg.files[0].name.endsWith(".mkv"))
}
@Test
fun downloadToGlassesWhenMirrorNotReadyReturnsHint() = runTest {
val sender = SenderStub()
val mirrorClient = mirror { request -> respond("", HttpStatusCode.NotFound) }
val out = actions(simpleJellyfin(), mirrorClient, sender).downloadToGlasses("m1")
assertTrue(out.contains("не готово"))
assertTrue(sender.sent.isEmpty())
}
@Test
fun downloadAndDeleteDownloadedEmulate() = runTest {
val sender = SenderStub()
val actions = actions(simpleJellyfin(), doneMirror(), sender)
@@ -288,6 +328,17 @@ class PhoneActionsTest {
assertTrue(rm2.contains("не было скачано"))
}
@Test
fun deleteDownloadedSendsDeleteFilesToGlasses() = runTest {
val sender = SenderStub()
actions(simpleJellyfin(), doneMirror(), sender).deleteDownloaded("m1")
assertEquals(1, sender.sent.size)
val msg = sender.sent.single()
assertIs<DeleteFiles>(msg)
assertEquals("m1", msg.itemId)
}
@Test
fun statusShowsCurrentState() = runTest {
val sender = SenderStub()
@@ -298,7 +349,7 @@ class PhoneActionsTest {
assertTrue(empty.contains("ничего"))
assertTrue(empty.contains("пауза"))
actions.watch("m1")
actions.watch("m1", localVideoPath = "file:///x/m1/video.mkv")
val after = actions.status()
assertTrue(after.contains("Играет: Интерстеллар (m1)"))
assertTrue(after.contains("играет"))
@@ -37,6 +37,77 @@ class GlassesToHostTest {
assertEquals(32.0, decoded.storageTotalGb)
}
@Test
fun glassesStatusExtendedFieldsRoundTrip() {
val msg: GlassesToHost = GlassesStatus(
batteryPercent = 60,
storageUsedGb = 12.5,
storageTotalGb = 32.0,
storageTotalBytes = 34_359_738_368,
storageFreeBytes = 20_971_520_000,
mediaBytes = 1_863_946_240,
downloadedItemIds = listOf("i1", "i2"),
mediaDir = "/data/user/0/pw.binom.viewmate.glasses/files/viewmate",
appVersion = "0.2.0",
)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<GlassesStatus>(decoded)
assertEquals(60, decoded.batteryPercent)
assertEquals(34_359_738_368, decoded.storageTotalBytes)
assertEquals(20_971_520_000, decoded.storageFreeBytes)
assertEquals(1_863_946_240, decoded.mediaBytes)
assertEquals(listOf("i1", "i2"), decoded.downloadedItemIds)
assertEquals("/data/user/0/pw.binom.viewmate.glasses/files/viewmate", decoded.mediaDir)
assertEquals("0.2.0", decoded.appVersion)
}
@Test
fun glassesStatusDefaultsForOldFields() {
val msg: GlassesToHost = GlassesStatus(batteryPercent = 70, storageUsedGb = 10.5, storageTotalGb = 32.0)
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
assertIs<GlassesStatus>(decoded)
assertEquals(0, decoded.storageTotalBytes)
assertEquals(0, decoded.storageFreeBytes)
assertEquals(0, decoded.mediaBytes)
assertTrue(decoded.downloadedItemIds.isEmpty())
assertEquals("", decoded.mediaDir)
assertEquals("", decoded.appVersion)
}
@Test
fun downloadProgressRoundTrip() {
val msg: GlassesToHost = DownloadProgress(
itemId = "movie-1",
fileName = "video.mkv",
percent = 45,
doneBytes = 838_860_800,
totalBytes = 1_863_946_240,
phase = DownloadPhase.DOWNLOADING,
)
val text = json.encodeToString(GlassesToHost.serializer(), msg)
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
assertIs<DownloadProgress>(decoded)
assertEquals("movie-1", decoded.itemId)
assertEquals("video.mkv", decoded.fileName)
assertEquals(45, decoded.percent)
assertEquals(838_860_800, decoded.doneBytes)
assertEquals(1_863_946_240, decoded.totalBytes)
assertEquals(DownloadPhase.DOWNLOADING, decoded.phase)
}
@Test
fun downloadProgressDoneAndFailedPhases() {
val done: GlassesToHost = DownloadProgress("i", "video.mkv", 100, 10, 10, DownloadPhase.DONE)
val failed: GlassesToHost = DownloadProgress("i", "audio-0.ogg", 0, 5, 10, DownloadPhase.FAILED)
val decodedDone = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), done))
val decodedFailed = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), failed))
assertIs<DownloadProgress>(decodedDone)
assertEquals(DownloadPhase.DONE, decodedDone.phase)
assertIs<DownloadProgress>(decodedFailed)
assertEquals(DownloadPhase.FAILED, decodedFailed.phase)
}
@Test
fun playbackPositionRoundTrip() {
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
@@ -9,6 +9,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HostToGlassesTest {
@@ -113,6 +114,57 @@ class HostToGlassesTest {
assertEquals("Планы на выходные", decoded.title)
}
@Test
fun downloadFilesRoundTrip() {
val msg: HostToGlasses = DownloadFiles(
itemId = "movie-1",
title = "Interstellar",
files = listOf(
FileSpec(url = "https://s3/mirror/video.mkv?sig", name = "video.mkv", sizeBytes = 1_863_946_240),
FileSpec(url = "https://s3/mirror/audio-0.ogg?sig", name = "audio-0.ogg", sizeBytes = 68_157_440),
),
)
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<DownloadFiles>(decoded)
assertEquals("movie-1", decoded.itemId)
assertEquals("Interstellar", decoded.title)
assertEquals(2, decoded.files.size)
assertEquals("video.mkv", decoded.files[0].name)
assertEquals(1_863_946_240, decoded.files[0].sizeBytes)
assertTrue(decoded.files[0].url.startsWith("https://s3/mirror/video.mkv"))
assertEquals("audio-0.ogg", decoded.files[1].name)
}
@Test
fun downloadFilesDefaultSize() {
val msg: HostToGlasses = DownloadFiles(
itemId = "i",
title = "t",
files = listOf(FileSpec(url = "https://s3/f", name = "video.mkv")),
)
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
assertIs<DownloadFiles>(decoded)
assertEquals(0, decoded.files[0].sizeBytes)
}
@Test
fun deleteFilesRoundTrip() {
val msg: HostToGlasses = DeleteFiles(itemId = "movie-1")
val text = json.encodeToString(HostToGlasses.serializer(), msg)
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
assertIs<DeleteFiles>(decoded)
assertEquals("movie-1", decoded.itemId)
}
@Test
fun playVideoEmptyVideoUrlAllowed() {
val msg: HostToGlasses = PlayVideo(itemId = "i", title = "t", videoUrl = "")
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
assertIs<PlayVideo>(decoded)
assertEquals("", decoded.videoUrl)
}
@Test
fun discriminatorIsType() {
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))