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
@@ -50,26 +50,55 @@ internal fun decideSync(
return SyncDecision(seekToMs = seekToMs, playing = playing)
}
/**
* Результат выбора аудио-источника: [source] (локальный путь или presigned URL)
* либо null с человекочитаемой [reason] — чтобы лог показал точную причину отсутствия звука.
*/
internal data class AudioSourceResult(
val source: String?,
val reason: String,
)
/**
* Источник аудио для просмотра на очках: локальный скачанный `audio-N.ogg`
* (если есть — быстрее, без сети), иначе presigned URL дорожки [audioIndex]
* (стриминг, без скачивания). null — дорожки нет.
* (стриминг, без скачивания). null — дорожки нет, причина в [AudioSourceResult.reason].
* Чистая функция (без Android) — покрыта unit-тестами.
*/
internal fun resolveAudioSourceDetailed(
mirrorFiles: MirrorFiles?,
audioIndex: Int,
localAudioFile: (index: Int) -> File,
presign: (url: String) -> String = { S3Presigner.presignGet(it, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION) },
): AudioSourceResult {
if (mirrorFiles == null) {
return AudioSourceResult(null, "files=null (зеркало не получено)")
}
val audio = mirrorFiles.audios.firstOrNull { it.index == audioIndex } ?: mirrorFiles.audios.firstOrNull()
if (audio == null) {
return AudioSourceResult(null, "audios пусто (искал дорожку #$audioIndex)")
}
val local = localAudioFile(audio.index)
return if (local.exists()) {
AudioSourceResult(local.absolutePath, "локальный файл ${local.absolutePath}")
} else {
AudioSourceResult(presign(audio.url), "стриминг (локального audio-${audio.index}.ogg на телефоне нет)")
}
}
/**
* Public-обёртка для UI: источник аудио по [DownloadManager] (S3-presign по умолчанию).
*/
fun resolveAudioSource(
downloadManager: DownloadManager,
itemId: String,
mirrorFiles: MirrorFiles?,
audioIndex: Int,
): String? {
val files = mirrorFiles ?: return null
val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() ?: return null
val local = downloadManager.audioFile(itemId, audio.index)
return if (local.exists()) {
local.absolutePath
} else {
S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION)
}
}
): String? = resolveAudioSourceDetailed(
mirrorFiles = mirrorFiles,
audioIndex = audioIndex,
localAudioFile = { downloadManager.audioFile(itemId, it) },
).source
/**
* Аудио-плеер телефона, синхронизированный с мастер-таймкодом очков.