fix(app-phone): звук при watch (диагностика+подстраховка mirrorByItem) + качка не воскресает после удаления
- onWatch: явные логи в каждой точке отказа (watch result / audio source / уже играет / не скачано / mediaDir пуст), свежий mirrorByItem если files не догрузился, звук стартует с позиции очков при уже идущем видео - resolveAudioSource → resolveAudioSourceDetailed (AudioSourceResult с причиной null) - DownloadManager: поколения generations — delete() инвалидирует старую корутину (не воскрешает качку), ensureActive перед .part/rename, jobs.remove(itemId, job) - Тесты 62/0 (в т.ч. поколения — поймали null != 0L) Инфраструктурный блокер (не код): SeaweedFS на Server3 76.66 в IO-шторме (load ~184, wa 20%, чужой yt-dlp на хосте) — большие GET с s3.binom.pw висят/рвутся с ~14:00; качка на очки FAILED по этой причине.
This commit is contained in:
@@ -19,6 +19,7 @@ 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
|
||||
@@ -47,6 +48,10 @@ data class DownloadStatus(
|
||||
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 {
|
||||
@@ -82,9 +87,96 @@ internal data class DiskScanResult(
|
||||
)
|
||||
|
||||
/**
|
||||
* Скан папок filesDir/viewmate/<itemId>: финальный video.mkv → itemId в downloaded
|
||||
* (заодно чистим лишние .part); есть только .part → фаза downloading с percent
|
||||
* из размера .part и totalBytes из статуса.
|
||||
* Удалить состояние скачивания [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}" }
|
||||
|
||||
/** Полностью ли скачано на диск: все финальные файлы [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,
|
||||
@@ -96,17 +188,23 @@ internal fun scanDownloads(
|
||||
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()
|
||||
val biggestPart = parts.maxByOrNull { it.length() }
|
||||
val hasFinal = File(dir, "video.mkv").exists()
|
||||
|
||||
if (hasFinal) {
|
||||
// старый формат (без 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 (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
|
||||
} 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)
|
||||
}
|
||||
@@ -123,7 +221,7 @@ internal fun requestFor(url: String, existingBytes: Long): Request {
|
||||
}
|
||||
|
||||
/**
|
||||
* Реальное скачивание зеркала (видео + выбранная озвучка) с прогрессом.
|
||||
* Реальное скачивание зеркала (видео + все аудио-дорожки) с общим прогрессом.
|
||||
* Файлы: filesDir/viewmate/{itemId}/video.mkv + audio-{index}.ogg.
|
||||
* Скачивание пишется в .part и переживает рестарт: статус хранится в status.json,
|
||||
* при старте диск сканируется, докачка — только по явному тапу пользователя.
|
||||
@@ -135,6 +233,13 @@ class DownloadManager(
|
||||
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()
|
||||
@@ -180,6 +285,8 @@ class DownloadManager(
|
||||
percent = p.percent,
|
||||
totalBytes = statuses[itemId]?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = statuses[itemId]?.expectedFiles ?: emptyList(),
|
||||
fileSizes = statuses[itemId]?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
statusStore.write(statuses)
|
||||
@@ -213,74 +320,122 @@ class DownloadManager(
|
||||
/** Видео уже скачано на телефон. */
|
||||
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)
|
||||
/**
|
||||
* Полностью ли скачано зеркало: video.mkv + все аудио из [files] (без .part, >0 байт).
|
||||
* Список дорожек берётся из зеркала (files.audios), а не из того, что есть на диске.
|
||||
*/
|
||||
fun isFullyDownloaded(itemId: String, files: MirrorFiles?): Boolean {
|
||||
val keys = files?.let { fileKeys(it) } ?: return false
|
||||
if (keys.isEmpty()) return false
|
||||
return isFullyOnDisk(dirFor(itemId), keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Начать (или продолжить) загрузку зеркала [itemId] (видео + озвучка [audioIndex]).
|
||||
* Если уже качается — повторный вызов игнорируется; если файл полный — тоже.
|
||||
* При наличии .part докачивает с места обрыва (Range: bytes={размер}-).
|
||||
*/
|
||||
fun startDownload(itemId: String, audioIndex: Int) {
|
||||
if (jobs[itemId]?.isActive == true) return
|
||||
if (isDownloaded(itemId)) return
|
||||
/** Общий процент скачанного на диск по всем дорожкам (по .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)
|
||||
}
|
||||
|
||||
jobs[itemId] = scope.launch {
|
||||
/** Известные размеры дорожек (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) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
if (!stale()) update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "зеркало не готово"))
|
||||
return@launch
|
||||
}
|
||||
|
||||
val keys = fileKeys(files)
|
||||
val dir = dirFor(itemId)
|
||||
if (stale()) return@launch
|
||||
dir.mkdirs()
|
||||
|
||||
downloadFile(
|
||||
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) },
|
||||
)
|
||||
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 audio = files.audios.firstOrNull { it.index == audioIndex } ?: files.audios.firstOrNull()
|
||||
if (audio != null) {
|
||||
val totals = HashMap<String, Long>()
|
||||
val done = HashMap<String, Long>()
|
||||
keys.forEach { key ->
|
||||
if (stale()) return@launch
|
||||
val url = if (key == "video") files.video.url
|
||||
else files.audios.firstOrNull { "audio-${it.index}" == key }?.url ?: return@forEach
|
||||
val stage = if (key == "video") "видео" else "звук"
|
||||
downloadFile(
|
||||
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, "звук"))
|
||||
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))
|
||||
},
|
||||
onTotal = { total -> setTotalBytes(itemId, total) },
|
||||
)
|
||||
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) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
|
||||
// после delete() не воскрешаем статус «отменено» — состояние уже сброшено
|
||||
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.CANCELED, 0, "отменено"))
|
||||
}
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
log("download", "ошибка скачивания $itemId: ${e.message}")
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
||||
if (isCurrentGeneration(generations, itemId, generation)) {
|
||||
log("download", "ошибка скачивания $itemId: ${e.message}")
|
||||
update(itemId, DownloadProgress(itemId, DownloadPhase.FAILED, 0, "ошибка: ${e.message}"))
|
||||
}
|
||||
} finally {
|
||||
jobs.remove(itemId)
|
||||
// снимаем ТОЛЬКО свою корутину: после cancel/delete в jobs могла лечь новая
|
||||
jobs.remove(itemId, job)
|
||||
_activeIds.value = _activeIds.value - itemId
|
||||
}
|
||||
}
|
||||
jobs[itemId] = job
|
||||
}
|
||||
|
||||
/** Отменить текущую загрузку (.part остаётся — при следующем тапе докачается). */
|
||||
@@ -288,32 +443,34 @@ class DownloadManager(
|
||||
jobs.remove(itemId)?.cancel()
|
||||
}
|
||||
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. */
|
||||
/** Удалить скачанные файлы с телефона и сбросить статус. Удаление финально. */
|
||||
fun delete(itemId: String) {
|
||||
cancel(itemId)
|
||||
// инвалидируем незавершённую корутину: даже если она проснётся позже — дальше не пойдёт
|
||||
generations[itemId] = (generations[itemId] ?: 0L) + 1
|
||||
dirFor(itemId).deleteRecursively()
|
||||
_downloads.value = _downloads.value - itemId
|
||||
_downloadedIds.value = _downloadedIds.value - itemId
|
||||
statuses.remove(itemId)
|
||||
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 {
|
||||
val total = statuses[itemId]?.totalBytes ?: 0L
|
||||
if (total <= 0) return 0
|
||||
return partPercent(itemId, partVideo(itemId))
|
||||
}
|
||||
/** Сколько уже скачано (для фазы «получаю зеркало» при докачке) — общий процент по всем дорожкам. */
|
||||
private fun resumePercent(itemId: String): Int = overallPercent(itemId)
|
||||
|
||||
/**
|
||||
* Скачивание одного файла в [part] (докачка с Range, если уже есть данные),
|
||||
* по завершении — rename в [dest] (без .part).
|
||||
* по завершении — rename в [dest] (без .part). [onProgress] отдаёт (скачано, всего).
|
||||
*/
|
||||
private suspend fun downloadFile(
|
||||
url: String,
|
||||
part: File,
|
||||
dest: File,
|
||||
onProgress: (Int) -> Unit,
|
||||
onProgress: (done: Long, total: Long) -> Unit,
|
||||
onTotal: (Long) -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val existing = if (part.exists()) part.length() else 0L
|
||||
@@ -334,6 +491,8 @@ class DownloadManager(
|
||||
onTotal(total)
|
||||
|
||||
val input = body.byteStream()
|
||||
// перед созданием/открытием .part — стоп, если качку отменили/удалили
|
||||
currentCoroutineContext().ensureActive()
|
||||
FileOutputStream(part, resumed).use { out ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var done = from
|
||||
@@ -343,13 +502,13 @@ class DownloadManager(
|
||||
if (read < 0) break
|
||||
out.write(buffer, 0, read)
|
||||
done += read
|
||||
if (total > 0) {
|
||||
val percent = ((done * 100) / total).toInt().coerceIn(0, 100)
|
||||
onProgress(percent)
|
||||
}
|
||||
if (total > 0) onProgress(done, total)
|
||||
}
|
||||
}
|
||||
|
||||
// перед финализацией (.part → конечный файл) — стоп, если качку отменили/удалили,
|
||||
// иначе удаление «воскреснет» переименованным .part
|
||||
currentCoroutineContext().ensureActive()
|
||||
if (dest.exists()) dest.delete()
|
||||
if (!part.renameTo(dest)) {
|
||||
part.copyTo(dest, overwrite = true)
|
||||
@@ -358,22 +517,49 @@ class DownloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setTotalBytes(itemId: String, total: Long) {
|
||||
if (total <= 0) return
|
||||
private fun setFileSize(itemId: String, key: String, size: Long) {
|
||||
if (size <= 0) return
|
||||
val prev = statuses[itemId]
|
||||
if (prev != null && prev.totalBytes > 0) return
|
||||
statuses[itemId] = prev?.copy(totalBytes = total) ?: DownloadStatus(totalBytes = total)
|
||||
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 = statuses[itemId]?.totalBytes ?: 0L,
|
||||
totalBytes = prev?.totalBytes ?: 0L,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
expectedFiles = prev?.expectedFiles ?: emptyList(),
|
||||
fileSizes = prev?.fileSizes ?: emptyMap(),
|
||||
)
|
||||
persistStatus(force = progress.phase != DownloadPhase.DOWNLOADING)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user