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