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
@@ -15,6 +15,8 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import pw.binom.viewmate.core.GlassesMode
import pw.binom.viewmate.core.phone.PhoneState
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.core.protocol.DownloadProgress
import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.GlassesStatus
import pw.binom.viewmate.core.protocol.GlassesToHost
@@ -112,6 +114,55 @@ class GlassesServerTest {
}
}
@Test
fun downloadProgressUpdatesHubPerFileMap() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "video.mkv", 45, 450, 1000, DownloadPhase.DOWNLOADING)))
send(json.encodeToString(GlassesToHost.serializer(), DownloadProgress("i1", "audio-0.ogg", 100, 100, 100, DownloadPhase.DONE)))
}
awaitTrue { hub.glassesDownloads.value["i1"]?.size == 2 }
assertEquals(45, hub.glassesDownloads.value["i1"]?.get("video.mkv")?.percent)
assertEquals(DownloadPhase.DONE, hub.glassesDownloads.value["i1"]?.get("audio-0.ogg")?.phase)
}
@Test
fun extendedGlassesStatusUpdatesHubFields() = testApplication {
val hub = GlassesHub(PhoneState())
application { glassesServerModule(hub) }
val client = createClient { install(WebSockets) }
client.webSocket(GLASSES_WS_PATH) {
send(
json.encodeToString(
GlassesToHost.serializer(),
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/glasses/files/viewmate",
appVersion = "0.2.0",
),
),
)
}
awaitTrue { hub.downloadedItemIds.value.contains("i1") }
assertEquals(34_359_738_368L, hub.storageTotalBytes.value)
assertEquals(20_971_520_000L, hub.storageFreeBytes.value)
assertEquals(1_863_946_240L, hub.mediaBytes.value)
assertEquals("/data/glasses/files/viewmate", hub.mediaDir.value)
assertEquals("0.2.0", hub.appVersion.value)
}
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
val deadline = System.currentTimeMillis() + timeoutMs
while (!condition()) {