2844d6a3ac
Телефон качает audio-N.ogg (audioKeys вместо fileKeys, стадия «звук»), очки получают DownloadFiles только с video.mkv (buildGlassesFileSpecs). Watch проверяет готовность обоих: телефон — audioReady(), очки — как было; отказ с подсказкой «Скачать на телефон/очки». Тесты: lib-core 19, phone 36.
590 lines
28 KiB
Kotlin
590 lines
28 KiB
Kotlin
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<String> = emptyList(),
|
||
/** Размер каждой дорожки (key → байт), заполняется после получения заголовков. */
|
||
val fileSizes: Map<String, Long> = emptyMap(),
|
||
)
|
||
|
||
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>,
|
||
)
|
||
|
||
/**
|
||
* Удалить состояние скачивания [itemId] из всех карт. Идемпотентно:
|
||
* повторный вызов ничего не меняет (карты уже не содержат itemId).
|
||
*/
|
||
internal fun removeDownloadState(
|
||
statuses: MutableMap<String, DownloadStatus>,
|
||
downloads: MutableMap<String, DownloadProgress>,
|
||
downloadedIds: MutableSet<String>,
|
||
itemId: String,
|
||
) {
|
||
downloads.remove(itemId)
|
||
downloadedIds.remove(itemId)
|
||
statuses.remove(itemId)
|
||
}
|
||
|
||
/**
|
||
* Актуальна ли качка поколения [generation] для [itemId]. [delete] увеличивает поколение —
|
||
* старая незавершённая корутина после удаления видит несовпадение и не «воскрешает» качку.
|
||
* Нет записи — поколение 0 (никогда не удалялось).
|
||
*/
|
||
internal fun isCurrentGeneration(generations: Map<String, Long>, 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<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
|
||
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<String>, sizes: Map<String, Long>): 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<String, Long>): 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<String, Long>, done: Map<String, Long>): 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>: все ожидаемые файлы → itemId в downloaded
|
||
* (заодно чистим лишние .part); есть только .part → фаза downloading с общим процентом
|
||
* по всем дорожкам. Список дорожек — из статуса (expectedFiles), а не с диска.
|
||
*/
|
||
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 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<String, Job>()
|
||
|
||
/**
|
||
* Поколение запуска качки itemId. Растёт при [delete]: незавершённая корутина
|
||
* после удаления видит несовпадение поколений и «умирает», не создавая файлы/статус заново.
|
||
*/
|
||
private val generations = ConcurrentHashMap<String, Long>()
|
||
|
||
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) // длинные файлы, паузы в потоке
|
||
.build()
|
||
|
||
private val _downloads = MutableStateFlow<Map<String, DownloadProgress>>(emptyMap())
|
||
val downloads: StateFlow<Map<String, DownloadProgress>> = _downloads.asStateFlow()
|
||
|
||
private val _downloadedIds = MutableStateFlow<Set<String>>(emptySet())
|
||
/** itemId, у которых звук уже скачан на телефон (переживает рестарт приложения). */
|
||
val downloadedIds: StateFlow<Set<String>> = _downloadedIds.asStateFlow()
|
||
|
||
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(),
|
||
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<File> =
|
||
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()
|
||
|
||
/**
|
||
* Полностью ли скачан звук зеркала на телефон: все аудио из [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<String, Long> =
|
||
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<String, Long>()
|
||
val done = HashMap<String, Long>()
|
||
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<String>) {
|
||
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)
|
||
}
|
||
}
|