feat: разделение контента — на телефон только аудио, на очки только видео
Телефон качает audio-N.ogg (audioKeys вместо fileKeys, стадия «звук»), очки получают DownloadFiles только с video.mkv (buildGlassesFileSpecs). Watch проверяет готовность обоих: телефон — audioReady(), очки — как было; отказ с подсказкой «Скачать на телефон/очки». Тесты: lib-core 19, phone 36.
This commit is contained in:
@@ -125,6 +125,10 @@ internal fun fileKeyFor(partName: String): String {
|
||||
internal fun fileKeys(files: MirrorFiles): List<String> =
|
||||
listOf("video") + files.audios.map { "audio-${it.index}" }
|
||||
|
||||
/** Ключи аудио-дорожек зеркала (audio-N) — телефон качает только звук. */
|
||||
internal fun audioKeys(files: MirrorFiles): List<String> =
|
||||
files.audios.map { "audio-${it.index}" }
|
||||
|
||||
/** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */
|
||||
internal fun isFullyOnDisk(dir: File, keys: List<String>): Boolean {
|
||||
if (keys.isEmpty()) return false
|
||||
@@ -205,7 +209,7 @@ internal fun scanDownloads(
|
||||
val biggest = parts.maxByOrNull { it.length() } ?: return@forEach
|
||||
if (total > 0) ((biggest.length() * 100) / total).toInt().coerceIn(0, 99) else 0
|
||||
}
|
||||
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео"
|
||||
val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "звук"
|
||||
progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage)
|
||||
}
|
||||
}
|
||||
@@ -221,8 +225,8 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
|
||||
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
|
||||
* Реальное скачивание зеркала (только аудио-дорожки — звук на телефоне) с общим прогрессом.
|
||||
* Файлы: filesDir/viewmate/{itemId}/audio-{index}.ogg.
|
||||
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
||||
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
||||
*/
|
||||
@@ -251,7 +255,7 @@ class DownloadManager(
|
||||
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
|
||||
|
||||
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
|
||||
/** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */
|
||||
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
|
||||
|
||||
private val _activeIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
@@ -321,15 +325,25 @@ class DownloadManager(
|
||||
fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists()
|
||||
|
||||
/**
|
||||
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
|
||||
* Полностью ли скачан звук зеркала на телефон: все аудио из [files] (без .part, >0 байт).
|
||||
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
|
||||
*/
|
||||
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
|
||||
val keys = files?.let { fileKeys(it) } ?: return false
|
||||
val keys = files?.let { audioKeys(it) } ?: return false
|
||||
if (keys.isEmpty()) return false
|
||||
return isFullyOnDisk(dirFor(itemId), keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Все аудио-дорожки зеркала на диске (можно смотреть: звук будет).
|
||||
* Список дорожек — из статуса качки (expectedFiles), а не с диска.
|
||||
*/
|
||||
fun audioReady(itemId: String): Boolean {
|
||||
val status = statuses[itemId] ?: return false
|
||||
if (status.expectedFiles.isEmpty()) return false
|
||||
return isFullyOnDisk(dirFor(itemId), status.expectedFiles)
|
||||
}
|
||||
|
||||
/** Общий процент скачанного на диск по всем дорожкам (по .part и полным файлам). */
|
||||
fun overallPercent(itemId: String): Int {
|
||||
val st = statuses[itemId] ?: return 0
|
||||
@@ -352,7 +366,7 @@ class DownloadManager(
|
||||
percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0
|
||||
|
||||
/**
|
||||
* Начать (или продолжить) загрузку зеркала [itemId]: видео + ВСЕ аудио-дорожки.
|
||||
* Начать (или продолжить) загрузку зеркала [itemId]: ТОЛЬКО аудио-дорожки (звук на телефоне).
|
||||
* Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла.
|
||||
*/
|
||||
@@ -376,7 +390,7 @@ class DownloadManager(
|
||||
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
return@launch
|
||||
}
|
||||
val keys = fileKeys(files)
|
||||
val keys = audioKeys(files)
|
||||
val dir = dirFor(itemId)
|
||||
if (stale()) return@launch
|
||||
dir.mkdirs()
|
||||
@@ -393,9 +407,8 @@ class DownloadManager(
|
||||
val done = HashMap<String, Long>()
|
||||
keys.forEach { key ->
|
||||
if (stale()) return@launch
|
||||
val url = if (key == "video") files.video.url
|
||||
else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
|
||||
val stage = if (key == "video") "видео" else "звук"
|
||||
val url = files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
|
||||
val stage = "звук"
|
||||
downloadFile(
|
||||
url = S3Presigner.presignGet(url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION),
|
||||
part = File(dir, partFileName(key)),
|
||||
|
||||
@@ -150,6 +150,12 @@ fun DetailsScreen(
|
||||
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на очки»") }
|
||||
return
|
||||
}
|
||||
val audioOk = dm.audioReady(item.Id)
|
||||
if (!audioOk) {
|
||||
log("watch", "пропущено: звук не скачан на телефон (audioReady=false)")
|
||||
scope.launch { snackbar.showSnackbar("сначала нажми «Скачать на телефон»") }
|
||||
return
|
||||
}
|
||||
if (glassesMediaDir.isNullOrBlank()) {
|
||||
log("watch", "пропущено: очки ещё не сообщили каталог (mediaDir пуст)")
|
||||
scope.launch { snackbar.showSnackbar("очки ещё не сообщили каталог — подожди") }
|
||||
@@ -179,7 +185,7 @@ fun DetailsScreen(
|
||||
log("watch", "видео уже играет на очках — PlayVideo не отправляю, запускаю только звук")
|
||||
} else {
|
||||
result = try {
|
||||
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath)
|
||||
actions!!.watch(item.Id, effectiveAudioIndex, localVideoPath = localPath, phoneAudioReady = audioOk)
|
||||
} catch (e: Exception) {
|
||||
log("watch", "watch упал: ${e.message}")
|
||||
snackbar.showSnackbar("watch упал: ${e.message}")
|
||||
|
||||
@@ -55,7 +55,7 @@ class DownloadManagerTest {
|
||||
|
||||
assertTrue(result.downloaded.isEmpty())
|
||||
assertEquals(
|
||||
DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "видео"),
|
||||
DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "звук"),
|
||||
result.progress["i2"],
|
||||
)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class DownloadManagerTest {
|
||||
|
||||
val result = scanDownloads(base, emptyMap())
|
||||
|
||||
assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "видео"), result.progress["i3"])
|
||||
assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "звук"), result.progress["i3"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -131,6 +131,27 @@ class DownloadManagerTest {
|
||||
assertEquals(listOf("video", "audio-0", "audio-1"), fileKeys(files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioKeysFromMirrorCoversOnlyAudios() {
|
||||
val files = MirrorFiles(
|
||||
video = MirrorVideo(key = "v", url = "http://v"),
|
||||
audios = listOf(
|
||||
MirrorAudio(index = 0, key = "a0", url = "http://a0"),
|
||||
MirrorAudio(index = 1, key = "a1", url = "http://a1"),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("audio-0", "audio-1"), audioKeys(files))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun audioKeysEmptyWithoutAudios() {
|
||||
val files = MirrorFiles(
|
||||
video = MirrorVideo(key = "v", url = "http://v"),
|
||||
audios = emptyList(),
|
||||
)
|
||||
assertTrue(audioKeys(files).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isFullyOnDiskRequiresAllFilesNoParts() {
|
||||
val base = newBaseDir()
|
||||
|
||||
@@ -38,11 +38,9 @@ class GlassesFilesTest {
|
||||
|
||||
assertEquals("movie-1", msg.itemId)
|
||||
assertEquals("Interstellar", msg.title)
|
||||
assertEquals(3, msg.files.size)
|
||||
assertEquals(1, 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
|
||||
@@ -56,8 +54,6 @@ class GlassesFilesTest {
|
||||
)
|
||||
|
||||
assertEquals(1_863_946_240L, msg.files[0].sizeBytes)
|
||||
assertEquals(68_157_440L, msg.files[1].sizeBytes)
|
||||
assertEquals(0L, msg.files[2].sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -67,8 +63,9 @@ class GlassesFilesTest {
|
||||
sizes = emptyMap(),
|
||||
presign = { "signed-$it" },
|
||||
)
|
||||
assertEquals(3, specs.size)
|
||||
assertEquals(1, specs.size)
|
||||
assertTrue(specs.all { it.url.startsWith("signed-") })
|
||||
assertEquals("video.mkv", specs.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-10
@@ -11,7 +11,8 @@ fun trackFileName(key: String): String = if (key == "video") "video.mkv" else "$
|
||||
fun trackKeys(files: MirrorFiles): List<String> =
|
||||
listOf("video") + files.audios.map { "audio-${it.index}" }
|
||||
|
||||
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен). */
|
||||
/** Собрать список файлов для DownloadFiles на очки: presigned URL, имя файла, размер (если известен).
|
||||
* На очки — только video.mkv; звук — на телефоне. */
|
||||
fun buildGlassesFileSpecs(
|
||||
files: MirrorFiles,
|
||||
sizes: Map<String, Long>,
|
||||
@@ -22,15 +23,7 @@ fun buildGlassesFileSpecs(
|
||||
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
|
||||
return listOf(video)
|
||||
}
|
||||
|
||||
/** Полное сообщение DownloadFiles для очков. */
|
||||
|
||||
@@ -89,8 +89,9 @@ class PhoneActions(
|
||||
/**
|
||||
* Включить на очках: зеркало должно быть done → PlayVideo с ЛОКАЛЬНЫМ путём на очках.
|
||||
* Стриминг запрещён: [localVideoPath] — file://... с диска очков (или пустой — не играем).
|
||||
* Звук — на телефоне: [phoneAudioReady] — скачаны ли аудио-дорожки на телефон.
|
||||
*/
|
||||
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = ""): String {
|
||||
suspend fun watch(itemId: String, audioIndex: Int = 0, localVideoPath: String = "", phoneAudioReady: Boolean = true): String {
|
||||
val item = jellyfin.item(userId, itemId) ?: return "элемент $itemId не найден"
|
||||
val status = mirror.mirrorByItem(itemId)
|
||||
val files = status?.files
|
||||
@@ -100,6 +101,9 @@ class PhoneActions(
|
||||
if (!localVideoPath.startsWith("file://")) {
|
||||
return "«${item.Name ?: itemId}» не скачан на очки — сначала «Скачать на очки»"
|
||||
}
|
||||
if (!phoneAudioReady) {
|
||||
return "«${item.Name ?: itemId}» не скачан на телефон (звук) — сначала «Скачать на телефон»"
|
||||
}
|
||||
|
||||
val title = item.Name ?: itemId
|
||||
sender.send(PlayVideo(itemId = itemId, title = title, videoUrl = localVideoPath, audioIndex = audioIndex))
|
||||
|
||||
@@ -187,6 +187,17 @@ class PhoneActionsTest {
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchWhenPhoneAudioNotReadyIsRejected() = runTest {
|
||||
val sender = SenderStub()
|
||||
val out = actions(simpleJellyfin(), doneMirror(), sender)
|
||||
.watch("m1", localVideoPath = "file:///x/m1/video.mkv", phoneAudioReady = false)
|
||||
|
||||
assertTrue(out.contains("не скачан на телефон"))
|
||||
assertTrue(out.contains("Скачать на телефон"))
|
||||
assertTrue(sender.sent.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun watchWhenMirrorNotReadyReturnsHint() = runTest {
|
||||
val sender = SenderStub()
|
||||
@@ -295,7 +306,7 @@ class PhoneActionsTest {
|
||||
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(listOf("video.mkv"), 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"))
|
||||
|
||||
Reference in New Issue
Block a user