diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index 0d3b0724..e1e3b60d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -51,26 +51,61 @@ internal actual object DownloadsPlatformDownloader { val downloadsDir = File(context.filesDir, "downloads").apply { mkdirs() } val destination = File(downloadsDir, request.destinationFileName) val tempFile = File(downloadsDir, "${request.destinationFileName}.part") - if (tempFile.exists()) tempFile.delete() - - val requestBuilder = Request.Builder().url(request.sourceUrl) - request.sourceHeaders.forEach { (key, value) -> - requestBuilder.header(key, value) - } - val httpRequest = requestBuilder.get().build() - call = downloadHttpClient.newCall(httpRequest) try { - call?.execute()?.use { response -> + var resumeFromBytes = tempFile.takeIf { it.exists() }?.length()?.coerceAtLeast(0L) ?: 0L + + fun buildRequest(rangeStart: Long?): Request { + val requestBuilder = Request.Builder().url(request.sourceUrl) + request.sourceHeaders.forEach { (key, value) -> + requestBuilder.header(key, value) + } + if (rangeStart != null && rangeStart > 0L) { + requestBuilder.header("Range", "bytes=$rangeStart-") + } + return requestBuilder.get().build() + } + + var attemptedRangeRequest = resumeFromBytes > 0L + var httpRequest = buildRequest(if (attemptedRangeRequest) resumeFromBytes else null) + call = downloadHttpClient.newCall(httpRequest) + var response = call?.execute() ?: error("Download request failed") + + if (attemptedRangeRequest && response.code == 416) { + response.close() + tempFile.delete() + resumeFromBytes = 0L + attemptedRangeRequest = false + httpRequest = buildRequest(null) + call = downloadHttpClient.newCall(httpRequest) + response = call?.execute() ?: error("Download request failed") + } + + response.use { response -> if (!response.isSuccessful) { error("Request failed with HTTP ${response.code}") } + + val isPartialResume = attemptedRangeRequest && response.code == 206 && resumeFromBytes > 0L + val appendToTemp = isPartialResume + val startingBytes = if (appendToTemp) resumeFromBytes else 0L + + if (!appendToTemp && tempFile.exists()) { + tempFile.delete() + } + val body = response.body ?: error("Empty response body") - val totalBytes = body.contentLength().takeIf { it > 0L } - var downloadedBytes = 0L + val totalBytes = resolveTotalBytes( + startingBytes = startingBytes, + isPartialResume = isPartialResume, + contentRangeHeader = response.header("Content-Range"), + contentLength = body.contentLength().takeIf { it > 0L }, + ) + var downloadedBytes = startingBytes + onProgress(downloadedBytes, totalBytes) body.byteStream().use { input -> - FileOutputStream(tempFile, false).use { output -> + FileOutputStream(tempFile, appendToTemp).use { output -> val buffer = ByteArray(16 * 1024) while (true) { ensureActive() @@ -95,10 +130,7 @@ internal actual object DownloadsPlatformDownloader { val finalSize = destination.length() onSuccess(destination.toURI().toString(), totalBytes ?: finalSize) } - } catch (_: CancellationException) { - tempFile.delete() } catch (error: Throwable) { - tempFile.delete() onFailure(error.message ?: "Download failed") } } @@ -115,6 +147,14 @@ internal actual object DownloadsPlatformDownloader { val file = localFileUri.toLocalFileOrNull() ?: return false return runCatching { file.delete() }.getOrDefault(false) } + + actual fun removePartialFile(destinationFileName: String): Boolean { + val context = appContext ?: return false + val downloadsDir = File(context.filesDir, "downloads") + val tempFile = File(downloadsDir, "$destinationFileName.part") + if (!tempFile.exists()) return true + return runCatching { tempFile.delete() }.getOrDefault(false) + } } private class AndroidDownloadsTaskHandle( @@ -134,3 +174,28 @@ private fun String.toLocalFileOrNull(): File? { } }.getOrNull() } + +private fun resolveTotalBytes( + startingBytes: Long, + isPartialResume: Boolean, + contentRangeHeader: String?, + contentLength: Long?, +): Long? { + parseContentRangeTotal(contentRangeHeader)?.let { return it } + val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null + return if (isPartialResume && startingBytes > 0L) { + startingBytes + normalizedLength + } else { + normalizedLength + } +} + +private fun parseContentRangeTotal(headerValue: String?): Long? { + val value = headerValue?.trim().orEmpty() + if (value.isBlank()) return null + val slashIndex = value.lastIndexOf('/') + if (slashIndex == -1 || slashIndex == value.lastIndex) return null + val totalPart = value.substring(slashIndex + 1).trim() + if (totalPart == "*") return null + return totalPart.toLongOrNull()?.takeIf { it > 0L } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt index 1d91ff46..b2a331ad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -19,4 +19,6 @@ internal expect object DownloadsPlatformDownloader { ): DownloadsTaskHandle fun removeFile(localFileUri: String?): Boolean + + fun removePartialFile(destinationFileName: String): Boolean } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index 25b29dd7..d60b97a0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -115,6 +115,7 @@ object DownloadsRepository { replacedExisting = true activeHandles.remove(existing.id)?.cancel() DownloadsPlatformDownloader.removeFile(existing.localFileUri) + DownloadsPlatformDownloader.removePartialFile(existing.fileName) currentItems.removeAll { it.id == existing.id } } @@ -194,8 +195,6 @@ object DownloadsRepository { val reset = item.copy( status = DownloadStatus.Downloading, - downloadedBytes = 0L, - totalBytes = null, errorMessage = null, localFileUri = null, updatedAtEpochMs = DownloadsClock.nowEpochMs(), @@ -216,6 +215,7 @@ object DownloadsRepository { activeHandles.remove(downloadId)?.cancel() DownloadsPlatformDownloader.removeFile(item.localFileUri) + DownloadsPlatformDownloader.removePartialFile(item.fileName) publish(_uiState.value.items.filterNot { it.id == downloadId }) persist() @@ -241,7 +241,6 @@ object DownloadsRepository { item } } - .sortedByDescending { it.updatedAtEpochMs } _uiState.value = DownloadsUiState(normalized) notifyLiveStatusPlatform() @@ -332,7 +331,7 @@ object DownloadsRepository { private fun publish(items: List) { _uiState.value = DownloadsUiState( - items = items.sortedByDescending { it.updatedAtEpochMs }, + items = items, ) notifyLiveStatusPlatform() } @@ -378,7 +377,7 @@ private object DownloadsCodec { fun encodeItems(items: Collection): String = json.encodeToString( StoredDownloadsPayload( - items = items.toList().sortedByDescending { it.updatedAtEpochMs }, + items = items.toList(), ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index 7158b18d..d1215d60 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Pause @@ -116,8 +117,10 @@ private fun LazyListScope.downloadsRootContent( item { SectionTitle("ACTIVE") } - items(activeItems.size) { index -> - val item = activeItems[index] + items( + items = activeItems, + key = { it.id }, + ) { item -> DownloadRow( item = item, onOpen = { onOpenDownload(item) }, @@ -133,8 +136,10 @@ private fun LazyListScope.downloadsRootContent( item { SectionTitle("MOVIES") } - items(completedMovies.size) { index -> - val item = completedMovies[index] + items( + items = completedMovies, + key = { it.id }, + ) { item -> DownloadRow( item = item, onOpen = { onOpenDownload(item) }, @@ -150,8 +155,10 @@ private fun LazyListScope.downloadsRootContent( item { SectionTitle("SHOWS") } - items(completedShows.size) { index -> - val (item, episodes) = completedShows[index] + items( + items = completedShows, + key = { (item, _) -> item.parentMetaId }, + ) { (item, episodes) -> Surface( modifier = Modifier .fillMaxWidth() @@ -262,8 +269,10 @@ private fun LazyListScope.downloadsShowContent( .thenByDescending { it.updatedAtEpochMs }, ) - items(sortedEpisodes.size) { index -> - val item = sortedEpisodes[index] + items( + items = sortedEpisodes, + key = { it.id }, + ) { item -> DownloadRow( item = item, onOpen = { onOpenDownload(item) }, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 746a742e..50cf133b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -52,24 +52,52 @@ internal actual object DownloadsPlatformDownloader { val destinationPath = "$downloadsDirectory/${request.destinationFileName}" val tempPath = "$downloadsDirectory/${request.destinationFileName}.part" - removePathIfExists(tempPath) - try { - val response = downloadHttpClient.get(request.sourceUrl) { + var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L + + suspend fun performRequest(rangeStart: Long?) = downloadHttpClient.get(request.sourceUrl) { request.sourceHeaders.forEach { (key, value) -> header(key, value) } + if (rangeStart != null && rangeStart > 0L) { + header("Range", "bytes=$rangeStart-") + } + } + + var attemptedRangeRequest = resumeFromBytes > 0L + var response = performRequest(if (attemptedRangeRequest) resumeFromBytes else null) + + if (attemptedRangeRequest && response.status.value == 416) { + removePathIfExists(tempPath) + resumeFromBytes = 0L + attemptedRangeRequest = false + response = performRequest(null) } if (!response.status.isSuccess()) { error("Request failed with HTTP ${response.status.value}") } - val totalBytes = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L } + val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L + val appendToTemp = isPartialResume + val startingBytes = if (appendToTemp) resumeFromBytes else 0L + + if (!appendToTemp) { + removePathIfExists(tempPath) + } + + val totalBytes = resolveTotalBytes( + startingBytes = startingBytes, + isPartialResume = isPartialResume, + contentRangeHeader = response.headers["Content-Range"], + contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L }, + ) val channel = response.bodyAsChannel() val wrote = writeChannelToFile( channel = channel, path = tempPath, + append = appendToTemp, + initialDownloadedBytes = startingBytes, totalBytes = totalBytes, onProgress = onProgress, ) @@ -90,10 +118,7 @@ internal actual object DownloadsPlatformDownloader { val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" val finalSize = fileSizeOrNull(destinationPath) onSuccess(localFileUri, totalBytes ?: finalSize) - } catch (_: CancellationException) { - removePathIfExists(tempPath) } catch (error: Throwable) { - removePathIfExists(tempPath) onFailure(error.message ?: "Download failed") } } @@ -106,6 +131,11 @@ internal actual object DownloadsPlatformDownloader { val path = localFileUri.toLocalPath() ?: return false return removePathIfExists(path) } + + actual fun removePartialFile(destinationFileName: String): Boolean { + val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" + return removePathIfExists(tempPath) + } } private class IosDownloadsTaskHandle( @@ -139,12 +169,15 @@ private fun removePathIfExists(path: String): Boolean { private suspend fun writeChannelToFile( channel: ByteReadChannel, path: String, + append: Boolean, + initialDownloadedBytes: Long, totalBytes: Long?, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, ): Boolean { - val file = fopen(path, "wb") ?: return false + val file = fopen(path, if (append) "ab" else "wb") ?: return false val buffer = ByteArray(16 * 1024) - var downloadedBytes = 0L + var downloadedBytes = initialDownloadedBytes + onProgress(downloadedBytes, totalBytes) return try { while (true) { @@ -192,3 +225,28 @@ private fun String.toLocalPath(): String? { } return takeIf { it.isNotBlank() } } + +private fun resolveTotalBytes( + startingBytes: Long, + isPartialResume: Boolean, + contentRangeHeader: String?, + contentLength: Long?, +): Long? { + parseContentRangeTotal(contentRangeHeader)?.let { return it } + val normalizedLength = contentLength?.takeIf { it > 0L } ?: return null + return if (isPartialResume && startingBytes > 0L) { + startingBytes + normalizedLength + } else { + normalizedLength + } +} + +private fun parseContentRangeTotal(headerValue: String?): Long? { + val value = headerValue?.trim().orEmpty() + if (value.isBlank()) return null + val slashIndex = value.lastIndexOf('/') + if (slashIndex == -1 || slashIndex == value.lastIndex) return null + val totalPart = value.substring(slashIndex + 1).trim() + if (totalPart == "*") return null + return totalPart.toLongOrNull()?.takeIf { it > 0L } +}