From 724f90a385928d1915fdc5ce3b3efb08b01bcf94 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 19 Aug 2026 13:46:03 +0300 Subject: [PATCH] =?UTF-8?q?fix(app-phone):=20=D0=BF=D0=B5=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BD=D1=82=D0=BD=D1=8B=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=82=D1=83=D1=81=20=D1=81=D0=BA=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20+=20=D0=B4=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D1=87=D0=BA=D0=B0=20=D1=81=20=D0=BC=D0=B5=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BE=D0=B1=D1=80=D1=8B=D0=B2=D0=B0=20+=20StatusBlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DownloadManager: .part-файлы, resume через Range (206/200), status.json (фаза/процент/total), скан диска при старте, guard от повторной качки - DetailsScreen: кнопка «✓ скачано на телефон»/«Продолжить (N%)», чипы из диска - Components: StatusBlock Box→Column (строки статуса перекрывались кнопкой «Удалить»); приоритет бейджа: «✓» done выше «↓ часть» - 8 новых тестов (Range, скан диска, status.json) --- .../binom/viewmate/phone/DownloadManager.kt | 283 ++++++++++++++++-- .../pw/binom/viewmate/phone/ui/Components.kt | 6 +- .../binom/viewmate/phone/ui/DetailsScreen.kt | 33 +- .../viewmate/phone/DownloadManagerTest.kt | 118 ++++++++ 4 files changed, 398 insertions(+), 42 deletions(-) create mode 100644 app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt index 378f3af..dd6939b 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/DownloadManager.kt @@ -12,10 +12,15 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import okhttp3.OkHttpClient import okhttp3.Request import pw.binom.viewmate.core.media.MirrorClient import java.io.File +import java.io.FileOutputStream import java.util.concurrent.ConcurrentHashMap /** Стадия загрузки файла на телефон. */ @@ -34,10 +39,94 @@ data class DownloadProgress( val stage: String = "", ) +/** Персистентный статус скачивания одного itemId (пишется в filesDir/viewmate/status.json). */ +@Serializable +data class DownloadStatus( + val phase: String = "downloading", + val stage: String = "", + val percent: Int = 0, + val totalBytes: Long = 0, + val updatedAt: Long = 0, +) + +internal val statusJson: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +/** Запись/чтение status.json: атомарно через временный файл. */ +internal class DownloadStatusStore(private val file: File) { + fun read(): Map = runCatching { + if (!file.exists()) return@runCatching emptyMap() + file.inputStream().use { stream -> + statusJson.decodeFromString>(stream.readBytes().decodeToString()) + } + }.getOrDefault(emptyMap()) + + fun write(statuses: Map) { + try { + file.parentFile?.mkdirs() + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(statusJson.encodeToString(statuses)) + if (!tmp.renameTo(file)) file.writeText(statusJson.encodeToString(statuses)) + } catch (e: Exception) { + log("download", "не удалось сохранить статус: ${e.message}") + } + } +} + +/** Результат сканирования диска при старте: полные файлы и незавершённые .part. */ +internal data class DiskScanResult( + val downloaded: Set, + val progress: Map, +) + +/** + * Скан папок filesDir/viewmate/: финальный video.mkv → itemId в downloaded + * (заодно чистим лишние .part); есть только .part → фаза downloading с percent + * из размера .part и totalBytes из статуса. + */ +internal fun scanDownloads( + baseDir: File, + saved: Map, +): DiskScanResult { + val downloaded = mutableSetOf() + val progress = mutableMapOf() + + baseDir.listFiles()?.forEach { dir -> + if (!dir.isDirectory) return@forEach + val itemId = dir.name + val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty() + val biggestPart = parts.maxByOrNull { it.length() } + val hasFinal = File(dir, "video.mkv").exists() + + if (hasFinal) { + downloaded += itemId + parts.forEach { it.delete() } + } else if (biggestPart != null && biggestPart.length() > 0) { + val savedStatus = saved[itemId] + val total = savedStatus?.totalBytes ?: 0L + val percent = if (total > 0) ((biggestPart.length() * 100) / total).toInt().coerceIn(0, 99) else 0 + val stage = savedStatus?.stage?.takeIf { it.isNotBlank() } ?: "видео" + progress[itemId] = DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, stage) + } + } + + return DiskScanResult(downloaded, progress) +} + +/** OkHttp-запрос на скачивание: при [existingBytes] > 0 докачиваем с Range-заголовком. */ +internal fun requestFor(url: String, existingBytes: Long): Request { + val builder = Request.Builder().url(url) + if (existingBytes > 0) builder.header("Range", "bytes=$existingBytes-") + return builder.build() +} + /** * Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом. * Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg. - * Прогресс живёт в StateFlow и переживает повороты (загрузка в scope приложения). + * Скачивание пишется в .part и переживает рестарт: статус хранится в status.json, + * при старте диск сканируется, докачка — только по явному тапу пользователя. */ class DownloadManager( context: Context, @@ -46,6 +135,8 @@ class DownloadManager( private val baseDir = File(context.filesDir, "viewmate") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val jobs = ConcurrentHashMap() + private val statuses = ConcurrentHashMap() + private val statusStore = DownloadStatusStore(File(baseDir, "status.json")) private val okHttp = OkHttpClient.Builder() .connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS) .readTimeout(0, java.util.concurrent.TimeUnit.MILLISECONDS) // длинные файлы, паузы в потоке @@ -54,13 +145,45 @@ class DownloadManager( private val _downloads = MutableStateFlow>(emptyMap()) val downloads: StateFlow> = _downloads.asStateFlow() - private val _downloadedIds = MutableStateFlow(scanExistingDownloads()) + private val _downloadedIds = MutableStateFlow>(emptySet()) /** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */ val downloadedIds: StateFlow> = _downloadedIds.asStateFlow() - private fun scanExistingDownloads(): Set = - baseDir.listFiles()?.filter { it.isDirectory && File(it, "video.mkv").exists() }?.map { it.name }?.toSet() - ?: emptySet() + private val _activeIds = MutableStateFlow>(emptySet()) + /** itemId, у которых прямо сейчас идёт активное скачивание (не «замороженный» .part). */ + val activeIds: StateFlow> = _activeIds.asStateFlow() + + init { + loadPersisted() + } + + /** Восстановить состояние из status.json + скан диска. */ + private fun loadPersisted() { + val saved = statusStore.read() + statuses.putAll(saved) + val scan = scanDownloads(baseDir, statuses) + _downloadedIds.value = scan.downloaded + val progress = scan.progress.toMutableMap() + // статусы из файла, которых нет на диске (failed/canceled) — показать как есть + saved.forEach { (itemId, st) -> + if (itemId in scan.downloaded || progress.containsKey(itemId)) return@forEach + if (st.phase == "failed" || st.phase == "canceled") { + progress[itemId] = DownloadProgress(itemId, DownloadPhase.valueOf(st.phase.uppercase()), st.percent, st.stage) + } + } + _downloads.value = progress + // обновить percent .part-элементов по размеру файлов на диске + scan.progress.forEach { (itemId, p) -> + statuses[itemId] = DownloadStatus( + phase = "downloading", + stage = p.stage, + percent = p.percent, + totalBytes = statuses[itemId]?.totalBytes ?: 0L, + updatedAt = System.currentTimeMillis(), + ) + } + statusStore.write(statuses) + } /** Папка скачанного для itemId. */ fun dirFor(itemId: String): File = File(baseDir, itemId) @@ -68,23 +191,49 @@ class DownloadManager( /** Файл видео на телефоне. */ fun videoFile(itemId: String): File = File(dirFor(itemId), "video.mkv") - /** Файлы озвучки на телефоне (audio-*.ogg). */ + /** Временный файл видео (.part). */ + fun partVideo(itemId: String): File = File(dirFor(itemId), "video.mkv.part") + + /** Файл озвучки на телефоне. */ + fun audioFile(itemId: String, index: Int): File = File(dirFor(itemId), "audio-$index.ogg") + + /** Временный файл озвучки (.part). */ + fun partAudio(itemId: String, index: Int): File = File(dirFor(itemId), "audio-$index.ogg.part") + + /** Файлы озвучки на телефоне (audio-*.ogg, без .part). */ fun audioFiles(itemId: String): List = - dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") }?.sortedBy { it.name } + dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") && !it.name.endsWith(".part") }?.sortedBy { it.name } + ?: emptyList() + + /** Неполные .part-файлы озвучки. */ + fun audioParts(itemId: String): List = + dirFor(itemId).listFiles()?.filter { it.isFile && it.name.startsWith("audio-") && it.name.endsWith(".part") }?.sortedBy { it.name } ?: emptyList() /** Видео уже скачано на телефон. */ fun isDownloaded(itemId: String): Boolean = videoFile(itemId).exists() + /** Процент неполного файла [part] от полного размера (totalBytes из статуса). */ + fun partPercent(itemId: String, part: File): Int { + if (!part.exists() || part.length() <= 0) return 0 + val total = statuses[itemId]?.totalBytes ?: 0L + if (total <= 0) return 0 + return ((part.length() * 100) / total).toInt().coerceIn(0, 99) + } + /** - * Начать загрузку зеркала [itemId] (видео + озвучка [audioIndex]). - * Если уже качается — повторный вызов игнорируется. + * Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]). + * Если уже качается — повторный вызов игнорируется; если файл полный — тоже. + * При наличии .part докачивает с места обрыва (Range: bytes={размер}-). */ fun startDownload(itemId: String, audioIndex: Int) { if (jobs[itemId]?.isActive == true) return + if (isDownloaded(itemId)) return + jobs[itemId] = scope.launch { + _activeIds.value = _activeIds.value + itemId try { - update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, 0, "получаю зеркало")) + update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало")) val status = mirror.mirrorByItem(itemId) val files = status?.files if (status == null || status.status != "done" || files == null) { @@ -96,21 +245,26 @@ class DownloadManager( dir.mkdirs() downloadFile( - S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), - videoFile(itemId), - ) { percent -> - update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео")) - } + url = S3Presigner.presignGet(files.video.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), + part = partVideo(itemId), + dest = videoFile(itemId), + onProgress = { percent -> + update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "видео")) + }, + onTotal = { total -> setTotalBytes(itemId, total) }, + ) val audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull() if (audio != null) { - val audioFile = File(dir, "audio-${audio.index}.ogg") downloadFile( - S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), - audioFile, - ) { percent -> - update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук")) - } + url = S3Presigner.presignGet(audio.url, PhoneConfig.S3_ACCESS_KEY, PhoneConfig.S3_SECRET_KEY, PhoneConfig.S3_REGION), + part = partAudio(itemId, audio.index), + dest = audioFile(itemId, audio.index), + onProgress = { percent -> + update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, percent, "звук")) + }, + onTotal = { total -> setTotalBytes(itemId, total) }, + ) } update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово")) @@ -122,33 +276,67 @@ class DownloadManager( } catch (e: Exception) { log("download", "ошибка скачивания $itemId: ${e.message}") update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}")) + } finally { + jobs.remove(itemId) + _activeIds.value = _activeIds.value - itemId } } } - /** Отменить текущую загрузку. */ + /** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */ fun cancel(itemId: String) { jobs.remove(itemId)?.cancel() } - /** Удалить скачанные файлы с телефона и сбросить прогресс. */ + /** Удалить скачанные файлы с телефона и сбросить статус. */ fun delete(itemId: String) { cancel(itemId) dirFor(itemId).deleteRecursively() _downloads.value = _downloads.value - itemId _downloadedIds.value = _downloadedIds.value - itemId + statuses.remove(itemId) + persistStatus(force = true) } - private suspend fun downloadFile(url: String, dest: File, onProgress: (Int) -> Unit) = withContext(Dispatchers.IO) { - val request = Request.Builder().url(url).build() - okHttp.newCall(request).execute().use { response -> - if (!response.isSuccessful) error("HTTP ${response.code} для $url") + /** Сколько уже скачано (для фазы «получаю зеркало» при докачке). */ + private fun resumePercent(itemId: String): Int { + val total = statuses[itemId]?.totalBytes ?: 0L + if (total <= 0) return 0 + return partPercent(itemId, partVideo(itemId)) + } + + /** + * Скачивание одного файла в [part] (докачка с Range, если уже есть данные), + * по завершении — rename в [dest] (без .part). + */ + private suspend fun downloadFile( + url: String, + part: File, + dest: File, + onProgress: (Int) -> Unit, + onTotal: (Long) -> Unit, + ) = withContext(Dispatchers.IO) { + val existing = if (part.exists()) part.length() else 0L + okHttp.newCall(requestFor(url, existing)).execute().use { response -> + val code = response.code + if (code != 200 && code != 206) error("HTTP $code для $url") val body = response.body ?: error("пустое тело ответа для $url") - val total = body.contentLength() + + // 206 — сервер уважил Range; если вернул 200, начинаем заново с нуля + val resumed = code == 206 && existing > 0 + val from = if (resumed) existing else 0L + val total = if (resumed) { + response.header("Content-Range")?.substringAfterLast('/')?.toLongOrNull() + ?: (from + body.contentLength()) + } else { + body.contentLength() + } + onTotal(total) + val input = body.byteStream() - dest.outputStream().use { out -> + FileOutputStream(part, resumed).use { out -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var done = 0L + var done = from while (true) { currentCoroutineContext().ensureActive() val read = input.read(buffer) @@ -156,14 +344,47 @@ class DownloadManager( out.write(buffer, 0, read) done += read if (total > 0) { - onProgress(((done.toDouble() / total) * 100).toInt().coerceIn(0, 100)) + val percent = ((done * 100) / total).toInt().coerceIn(0, 100) + onProgress(percent) } } } + + if (dest.exists()) dest.delete() + if (!part.renameTo(dest)) { + part.copyTo(dest, overwrite = true) + part.delete() + } } } + private fun setTotalBytes(itemId: String, total: Long) { + if (total <= 0) return + val prev = statuses[itemId] + if (prev != null && prev.totalBytes > 0) return + statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total) + persistStatus() + } + private fun update(itemId: String, progress: DownloadProgress) { _downloads.value = _downloads.value + (itemId to progress) + statuses[itemId] = DownloadStatus( + phase = progress.phase.name.lowercase(), + stage = progress.stage, + percent = progress.percent, + totalBytes = statuses[itemId]?.totalBytes ?: 0L, + updatedAt = System.currentTimeMillis(), + ) + persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING) + } + + private var lastPersistAt = 0L + + /** Запись status.json; прогресс пишем не чаще раза в полсекунды, терминальные фазы — всегда. */ + private fun persistStatus(force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force && now - lastPersistAt < 500) return + lastPersistAt = now + statusStore.write(statuses) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt index e946363..64e5d22 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt @@ -100,10 +100,10 @@ fun mirrorBadge( status == "failed" || status == "error" -> MirrorBadge("✗", PhoneRed, Color.White) - hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText) - status == "done" -> MirrorBadge("✓", PhoneGreen, PhoneGreenText) + hasLocal || hasAny -> MirrorBadge("↓ часть", PhoneBlue, PhoneBlueText) + else -> MirrorBadge("нет", Color(0xFF555555), PhoneTextDim) } } @@ -217,7 +217,7 @@ fun StatusLine(label: String, chip: @Composable () -> Unit) { /** Карточка-статус из макета (фон PhoneCard, скругление 10). */ @Composable fun StatusBlock(content: @Composable () -> Unit) { - Box( + Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(10.dp)) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt index 4d6706d..1f87a02 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -63,6 +63,7 @@ fun DetailsScreen( var audioIndex by remember(item.Id) { mutableStateOf(app.state.currentAudioIndex) } val downloadProgress by app.downloadManager.downloads.collectAsState() + val activeIds by app.downloadManager.activeIds.collectAsState() val downloadedMap by app.state.downloadedFlow.collectAsState() val localIds by app.downloadManager.downloadedIds.collectAsState() var audioDownloaded by remember(item.Id) { mutableStateOf(app.downloadManager.audioFiles(item.Id).isNotEmpty()) } @@ -89,7 +90,14 @@ fun DetailsScreen( val audios = files?.audios.orEmpty() val effectiveAudioIndex = if (audios.any { it.index == audioIndex }) audioIndex else audios.firstOrNull()?.index ?: 0 val download = downloadProgress[item.Id] - val downloading = download?.phase == DownloadPhase.DOWNLOADING + // «качается» только если идёт активное скачивание; восстановленный из статуса .part — не качается + val downloading = activeIds.contains(item.Id) + val videoPart = app.downloadManager.partVideo(item.Id) + val hasVideoPart = videoPart.length() > 0 + val audioPartFiles = app.downloadManager.audioParts(item.Id) + val hasAudioPart = audioPartFiles.any { it.length() > 0 } + val videoPartPercent = app.downloadManager.partPercent(item.Id, videoPart) + val audioPartPercent = audioPartFiles.maxOfOrNull { app.downloadManager.partPercent(item.Id, it) } ?: 0 fun onSelectAudio(index: Int) { audioIndex = index @@ -116,8 +124,12 @@ fun DetailsScreen( fun onDownloadToggle() { val dm = app.downloadManager - if (downloading) dm.cancel(item.Id) - else dm.startDownload(item.Id, effectiveAudioIndex) + when { + // файл уже полный — не запускаем повторную качку + dm.isDownloaded(item.Id) -> Unit + downloading -> dm.cancel(item.Id) + else -> dm.startDownload(item.Id, effectiveAudioIndex) + } } fun onDeleteDownloaded() { @@ -260,15 +272,15 @@ fun DetailsScreen( StatusLine("📱 Телефон — видео") { DeviceChip( downloaded = onPhoneVideo, - downloading = downloading && download?.stage == "видео", - percent = download?.percent ?: 0, + downloading = !onPhoneVideo && hasVideoPart, + percent = download?.percent ?: videoPartPercent, ) } StatusLine("📱 Телефон — звук") { DeviceChip( downloaded = onPhoneAudio, - downloading = downloading && download?.stage == "звук", - percent = download?.percent ?: 0, + downloading = !onPhoneAudio && hasAudioPart, + percent = download?.percent ?: audioPartPercent, ) } Spacer(modifier = Modifier.height(6.dp)) @@ -309,7 +321,12 @@ fun DetailsScreen( shape = RoundedCornerShape(10.dp), ) { Text( - if (downloading) "Отменить" else "Скачать на телефон", + when { + localIds.contains(item.Id) -> "✓ скачано на телефон" + downloading -> "Отменить" + hasVideoPart -> "Продолжить ($videoPartPercent%)" + else -> "Скачать на телефон" + }, fontSize = 13.sp, ) } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt new file mode 100644 index 0000000..25679f4 --- /dev/null +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/DownloadManagerTest.kt @@ -0,0 +1,118 @@ +package pw.binom.viewmate.phone + +import kotlinx.serialization.encodeToString +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DownloadManagerTest { + + private fun tempDir(): File = Files.createTempDirectory("dm-test").toFile() + + private fun newBaseDir(): File { + val base = tempDir() + base.deleteOnExit() + return base + } + + @Test + fun rangeHeaderFormedFromPartSize() { + val noResume = requestFor("https://s3/mirror/video.mkv", 0) + assertNull(noResume.header("Range"), "без .part Range-заголовка быть не должно") + + val resume = requestFor("https://s3/mirror/video.mkv", 1_863_946_240) + assertEquals("bytes=1863946240-", resume.header("Range")) + } + + @Test + fun scanFullFileMarksDownloadedAndRemovesPart() { + val base = newBaseDir() + val dir = File(base, "i1").apply { mkdirs() } + File(dir, "video.mkv").writeBytes(ByteArray(10)) + File(dir, "video.mkv.part").writeBytes(ByteArray(5)) // оставшийся после рестарта + + val result = scanDownloads(base, emptyMap()) + + assertEquals(setOf("i1"), result.downloaded) + assertTrue(result.progress.isEmpty()) + assertTrue(!File(dir, "video.mkv.part").exists(), ".part при наличии полного файла удаляется") + } + + @Test + fun scanPartOnlyMarksDownloadingWithPercent() { + val base = newBaseDir() + val dir = File(base, "i2").apply { mkdirs() } + File(dir, "video.mkv.part").writeBytes(ByteArray(500)) + val saved = mapOf("i2" to DownloadStatus(totalBytes = 1000)) + + val result = scanDownloads(base, saved) + + assertTrue(result.downloaded.isEmpty()) + assertEquals( + DownloadProgress("i2", DownloadPhase.DOWNLOADING, 50, "видео"), + result.progress["i2"], + ) + } + + @Test + fun scanPartWithoutTotalHasZeroPercent() { + val base = newBaseDir() + val dir = File(base, "i3").apply { mkdirs() } + File(dir, "video.mkv.part").writeBytes(ByteArray(500)) + + val result = scanDownloads(base, emptyMap()) + + assertEquals(DownloadProgress("i3", DownloadPhase.DOWNLOADING, 0, "видео"), result.progress["i3"]) + } + + @Test + fun statusFileWritesAndReads() { + val file = File(tempDir(), "status.json") + val store = DownloadStatusStore(file) + val statuses = mapOf( + "i1" to DownloadStatus(phase = "downloading", stage = "видео", percent = 42, totalBytes = 1_000_000, updatedAt = 1234L), + "i2" to DownloadStatus(phase = "done", percent = 100, totalBytes = 500, updatedAt = 5678L), + ) + + store.write(statuses) + val read = store.read() + + assertEquals(statuses, read) + assertTrue(file.exists()) + } + + @Test + fun statusFileMissingReadsEmpty() { + val store = DownloadStatusStore(File(tempDir(), "status.json")) + assertTrue(store.read().isEmpty()) + } + + @Test + fun statusSerializationMatchesSpecKeys() { + val text = statusJson.encodeToString( + mapOf("i1" to DownloadStatus(phase = "downloading", stage = "видео", percent = 50, totalBytes = 100, updatedAt = 42L)), + ) + assertTrue(text.contains("\"phase\"")) + assertTrue(text.contains("\"stage\"")) + assertTrue(text.contains("\"percent\"")) + assertTrue(text.contains("\"totalBytes\"")) + assertTrue(text.contains("\"updatedAt\"")) + assertTrue(text.contains("\"downloading\"")) + assertTrue(text.contains("\"видео\"")) + } + + @Test + fun scanIgnoresNonPartFilesWithoutFinalVideo() { + val base = newBaseDir() + val dir = File(base, "i4").apply { mkdirs() } + File(dir, "some-random.bin").writeBytes(ByteArray(10)) + + val result = scanDownloads(base, emptyMap()) + + assertTrue(result.downloaded.isEmpty()) + assertTrue(result.progress.isEmpty()) + } +}