package pw.binom.viewmate.phone import android.content.Context import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow 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 pw.binom.viewmate.core.media.MirrorFiles import java.io.File import java.io.FileOutputStream import java.util.concurrent.ConcurrentHashMap /** Стадия загрузки файла на телефон. */ enum class DownloadPhase { DOWNLOADING, DONE, FAILED, CANCELED, } /** Прогресс реального скачивания зеркала на телефон. */ data class DownloadProgress( val itemId: String, val phase: DownloadPhase, val percent: Int, 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, /** Ожидаемый набор дорожек: "video", "audio-0", ... — из зеркала, а не из того, что на диске. */ val expectedFiles: List = emptyList(), /** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */ val fileSizes: Map = emptyMap(), ) 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, ) /** * Удалить состояние скачивания [itemId] из всех карт. Идемпотентно: * повторный вызов ничего не меняет (карты уже не содержат itemId). */ internal fun removeDownloadState( statuses: MutableMap, downloads: MutableMap, downloadedIds: MutableSet, itemId: String, ) { downloads.remove(itemId) downloadedIds.remove(itemId) statuses.remove(itemId) } /** * Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение — * старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку. * Нет записи — поколение 0 (никогда не удалялось). */ internal fun isCurrentGeneration(generations: Map, itemId: String, generation: Long): Boolean = (generations[itemId] ?: 0L) == generation /** Имя финального файла для ключа дорожки ("video" → "video.mkv", "audio-0" → "audio-0.ogg"). */ internal fun finalFileName(key: String): String = if (key == "video") "video.mkv" else "$key.ogg" /** Имя .part-файла для ключа дорожки. */ internal fun partFileName(key: String): String = "${finalFileName(key)}.part" /** Ключ дорожки по имени .part-файла ("video.mkv.part" → "video", "audio-0.ogg.part" → "audio-0"). */ internal fun fileKeyFor(partName: String): String { val base = partName.removeSuffix(".part") return if (base == "video.mkv") "video" else base.removeSuffix(".ogg") } /** Список ключей дорожек зеркала: video + каждая аудио (из files.audios, а не с диска). */ internal fun fileKeys(files: MirrorFiles): List = listOf("video") + files.audios.map { "audio-${it.index}" } /** Ключи аудио-дорожек зеркала (audio-N) — телефон качает только звук. */ internal fun audioKeys(files: MirrorFiles): List = files.audios.map { "audio-${it.index}" } /** Полностью ли скачано на диск: все финальные файлы [keys] есть, >0 байт, без .part. */ internal fun isFullyOnDisk(dir: File, keys: List): Boolean { if (keys.isEmpty()) return false return keys.all { key -> val final = File(dir, finalFileName(key)) final.isFile && final.length() > 0 && !File(dir, partFileName(key)).exists() } } /** Общий процент скачанного на диск по .part/финальным файлам относительно [sizes]. */ internal fun diskPercent(dir: File, keys: List, sizes: Map): Int { if (keys.isEmpty()) return 0 val totalSum = keys.sumOf { sizes[it] ?: 0L } if (totalSum <= 0) return 0 var done = 0L keys.forEach { key -> val size = sizes[key] ?: 0L val part = File(dir, partFileName(key)) val final = File(dir, finalFileName(key)) done += when { part.isFile && part.length() > 0 -> if (size > 0) part.length().coerceAtMost(size) else 0L final.isFile && final.length() > 0 -> if (size > 0) size else final.length() else -> 0L } } return ((done * 100) / totalSum).toInt().coerceIn(0, 100) } /** Процент одного файла [fileName] по .part/финальному файлу относительно его размера; null, если посчитать нельзя. */ internal fun filePercent(dir: File, fileName: String, sizes: Map): Int? { val final = File(dir, fileName) if (final.isFile && final.length() > 0) return 100 val part = File(dir, "$fileName.part") if (part.isFile && part.length() > 0) { val total = sizes[fileKeyFor(part.name)] ?: 0L if (total > 0) return ((part.length() * 100) / total).toInt().coerceIn(0, 99) } return null } /** Общий процент по живым суммам байтов (скачано/всего по всем файлам). */ internal fun combinedPercent(totals: Map, done: Map): Int { val totalSum = totals.values.sum() if (totalSum <= 0) return 0 val doneSum = done.values.sum() return ((doneSum * 100) / totalSum).toInt().coerceIn(0, 100) } /** * Скан папок filesDir/viewmate/: все ожидаемые файлы → itemId в downloaded * (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом * по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска. */ 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 savedStatus = saved[itemId] val keys = savedStatus?.expectedFiles.orEmpty() val parts = dir.listFiles { f -> f.isFile && f.name.endsWith(".part") }?.toList().orEmpty() // старый формат (без expectedFiles): полным считаем наличие video.mkv val fully = if (keys.isNotEmpty()) isFullyOnDisk(dir, keys) else File(dir, "video.mkv").isFile if (fully) { downloaded += itemId parts.forEach { it.delete() } } else if (parts.any { it.length() > 0 }) { val percent = if (keys.isNotEmpty()) { diskPercent(dir, keys, savedStatus?.fileSizes.orEmpty()) } else { val total = savedStatus?.totalBytes ?: 0L 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() } ?: "звук" 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}/audio-{index}.ogg. * Скачивание пишется в .part и переживает рестарт: статус хранится в status.json, * при старте диск сканируется, докачка — только по явному тапу пользователя. */ class DownloadManager( context: Context, private val mirror: MirrorClient, ) { private val baseDir = File(context.filesDir, "viewmate") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val jobs = ConcurrentHashMap() /** * Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина * после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново. */ private val generations = 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) // длинные файлы, паузы в потоке .build() private val _downloads = MutableStateFlow>(emptyMap()) val downloads: StateFlow> = _downloads.asStateFlow() private val _downloadedIds = MutableStateFlow>(emptySet()) /** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */ val downloadedIds: StateFlow> = _downloadedIds.asStateFlow() 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(), expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(), fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(), ) } statusStore.write(statuses) } /** Папка скачанного для itemId. */ fun dirFor(itemId: String): File = File(baseDir, itemId) /** Файл видео на телефоне. */ fun videoFile(itemId: String): File = File(dirFor(itemId), "video.mkv") /** Временный файл видео (.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-") && !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() /** * Полностью ли скачан звук зеркала на телефон: все аудио из [files] (без .part, >0 байт). * Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске. */ fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean { 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 if (st.expectedFiles.isEmpty()) return 0 return diskPercent(dirFor(itemId), st.expectedFiles, st.fileSizes) } /** Известные размеры дорожек (key → байт) после скачивания на телефон; пусто, если качки не было. */ fun knownFileSizes(itemId: String): Map = statuses[itemId]?.fileSizes.orEmpty() /** Процент конкретного файла [fileName] ("video.mkv", "audio-0.ogg") на диске телефона; null, если посчитать нельзя. */ fun percentForFile(itemId: String, fileName: String): Int? { val st = statuses[itemId] return filePercent(dirFor(itemId), fileName, st?.fileSizes.orEmpty()) } /** Процент неполного файла [part] от полного размера дорожки (fileSizes/totalBytes из статуса). */ fun partPercent(itemId: String, part: File): Int = percentForFile(itemId, part.name.removeSuffix(".part")) ?: 0 /** * Начать (или продолжить) загрузку зеркала [itemId]: ТОЛЬКО аудио-дорожки (звук на телефоне). * Если уже качается — повторный вызов игнорируется; если всё полностью скачано — тоже. * При наличии .part докачивает с места обрыва (Range: bytes={размер}-) для каждого файла. */ fun startDownload(itemId: String) { if (jobs[itemId]?.isActive == true) return val generation = generations[itemId] ?: 0L lateinit var job: Job job = scope.launch { _activeIds.value = _activeIds.value + itemId try { // корутина «устарела»: itemId удалили (delete) — дальше не идём, // чтобы не создать заново каталог/статус/.part после удаления fun stale() = !isCurrentGeneration(generations, itemId, generation) if (stale()) return@launch update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, resumePercent(itemId), "получаю зеркало")) val status = mirror.mirrorByItem(itemId) if (stale()) return@launch val files = status?.files if (status == null || status.status != "done" || files == null) { if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово")) return@launch } val keys = audioKeys(files) val dir = dirFor(itemId) if (stale()) return@launch dir.mkdirs() if (isFullyOnDisk(dir, keys)) { update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово")) _downloadedIds.value = _downloadedIds.value + itemId log("download", "$itemId уже полностью скачан на диск") return@launch } setExpectedFiles(itemId, keys) val totals = HashMap() val done = HashMap() keys.forEach { key -> if (stale()) return@launch 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)), dest = File(dir, finalFileName(key)), onTotal = { total -> totals[key] = total setFileSize(itemId, key, total) }, onProgress = { d, t -> totals[key] = t done[key] = d update(itemId, DownloadProgress(itemId, DownloadPhase.DOWNLOADING, combinedPercent(totals, done), stage)) }, ) if (stale()) return@launch done[key] = totals[key] ?: 0L } if (stale()) return@launch update(itemId, DownloadProgress(itemId, DownloadPhase.DONE, 100, "готово")) _downloadedIds.value = _downloadedIds.value + itemId log("download", "скачано $itemId → $dir") } catch (e: kotlinx.coroutines.CancellationException) { // после delete() не воскрешаем статус «отменено» — состояние уже сброшено if (isCurrentGeneration(generations, itemId, generation)) { update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено")) } throw e } catch (e: Exception) { if (isCurrentGeneration(generations, itemId, generation)) { log("download", "ошибка скачивания $itemId: ${e.message}") update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}")) } } finally { // снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая jobs.remove(itemId, job) _activeIds.value = _activeIds.value - itemId } } jobs[itemId] = job } /** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */ fun cancel(itemId: String) { jobs.remove(itemId)?.cancel() } /** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */ fun delete(itemId: String) { cancel(itemId) // инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт generations[itemId] = (generations[itemId] ?: 0L) + 1 dirFor(itemId).deleteRecursively() val downloads = _downloads.value.toMutableMap() val downloadedIds = _downloadedIds.value.toMutableSet() removeDownloadState(statuses, downloads, downloadedIds, itemId) _downloads.value = downloads _downloadedIds.value = downloadedIds _activeIds.value = _activeIds.value - itemId persistStatus(force = true) log("download", "удалено скачанное $itemId (поколение → ${generations[itemId]})") } /** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */ private fun resumePercent(itemId: String): Int = overallPercent(itemId) /** * Скачивание одного файла в [part] (докачка с Range, если уже есть данные), * по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего). */ private suspend fun downloadFile( url: String, part: File, dest: File, onProgress: (done: Long, total: Long) -> 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") // 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() // перед созданием/открытием .part — стоп, если качку отменили/удалили currentCoroutineContext().ensureActive() FileOutputStream(part, resumed).use { out -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var done = from while (true) { currentCoroutineContext().ensureActive() val read = input.read(buffer) if (read < 0) break out.write(buffer, 0, read) done += read if (total > 0) onProgress(done, total) } } // перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили, // иначе удаление «воскреснет» переименованным .part currentCoroutineContext().ensureActive() if (dest.exists()) dest.delete() if (!part.renameTo(dest)) { part.copyTo(dest, overwrite = true) part.delete() } } } private fun setFileSize(itemId: String, key: String, size: Long) { if (size <= 0) return val prev = statuses[itemId] val sizes = prev?.fileSizes ?: emptyMap() if (sizes.containsKey(key)) return statuses[itemId] = DownloadStatus( phase = prev?.phase ?: "downloading", stage = prev?.stage ?: "", percent = prev?.percent ?: 0, totalBytes = (prev?.totalBytes ?: 0L) + size, updatedAt = System.currentTimeMillis(), expectedFiles = prev?.expectedFiles ?: emptyList(), fileSizes = sizes + (key to size), ) persistStatus() } private fun setExpectedFiles(itemId: String, keys: List) { val prev = statuses[itemId] if (prev?.expectedFiles == keys) return statuses[itemId] = DownloadStatus( phase = prev?.phase ?: "downloading", stage = prev?.stage ?: "", percent = prev?.percent ?: 0, totalBytes = prev?.totalBytes ?: 0L, updatedAt = System.currentTimeMillis(), expectedFiles = keys, fileSizes = prev?.fileSizes ?: emptyMap(), ) persistStatus() } private fun update(itemId: String, progress: DownloadProgress) { _downloads.value = _downloads.value + (itemId to progress) val prev = statuses[itemId] statuses[itemId] = DownloadStatus( phase = progress.phase.name.lowercase(), stage = progress.stage, percent = progress.percent, totalBytes = prev?.totalBytes ?: 0L, updatedAt = System.currentTimeMillis(), expectedFiles = prev?.expectedFiles ?: emptyList(), fileSizes = prev?.fileSizes ?: emptyMap(), ) 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) } }