fix(app-phone): персистентный статус скачивания + докачка с места обрыва + StatusBlock

- DownloadManager: .part-файлы, resume через Range (206/200), status.json (фаза/процент/total), скан диска при старте, guard от повторной качки
- DetailsScreen: кнопка «✓ скачано на телефон»/«Продолжить (N%)», чипы из диска
- Components: StatusBlock Box→Column (строки статуса перекрывались кнопкой «Удалить»); приоритет бейджа: «✓» done выше «↓ часть»
- 8 новых тестов (Range, скан диска, status.json)
This commit is contained in:
Hermes Agent
2026-08-19 13:46:03 +03:00
parent 6069400b88
commit 724f90a385
4 changed files with 398 additions and 42 deletions
@@ -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<String, DownloadStatus> = runCatching {
if (!file.exists()) return@runCatching emptyMap()
file.inputStream().use { stream ->
statusJson.decodeFromString<Map<String, DownloadStatus>>(stream.readBytes().decodeToString())
}
}.getOrDefault(emptyMap())
fun write(statuses: Map<String, DownloadStatus>) {
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<String>,
val progress: Map<String, DownloadProgress>,
)
/**
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
* из размера .part и totalBytes из статуса.
*/
internal fun scanDownloads(
baseDir: File,
saved: Map<String, DownloadStatus>,
): DiskScanResult {
val downloaded = mutableSetOf<String>()
val progress = mutableMapOf<String, DownloadProgress>()
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<String, Job>()
private val statuses = ConcurrentHashMap<String, DownloadStatus>()
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<Map<String, DownloadProgress>>(emptyMap())
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
private val _downloadedIds = MutableStateFlow(scanExistingDownloads())
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых видео уже скачано на телефон (переживает рестарт приложения). */
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
private fun scanExistingDownloads(): Set<String> =
baseDir.listFiles()?.filter { it.isDirectory && File(it, "video.mkv").exists() }?.map { it.name }?.toSet()
?: emptySet()
private val _activeIds = MutableStateFlow<Set<String>>(emptySet())
/** itemId, у которых прямо сейчас идёт активное скачивание (не «замороженный» .part). */
val activeIds: StateFlow<Set<String>> = _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<File> =
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<File> =
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)
}
}
@@ -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))
@@ -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,
)
}
@@ -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())
}
}