mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-01 00:59:27 +00:00
feat: partial download support and cleanup for incomplete files
This commit is contained in:
parent
0053d3ffaa
commit
b2de11a703
5 changed files with 170 additions and 37 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,4 +19,6 @@ internal expect object DownloadsPlatformDownloader {
|
|||
): DownloadsTaskHandle
|
||||
|
||||
fun removeFile(localFileUri: String?): Boolean
|
||||
|
||||
fun removePartialFile(destinationFileName: String): Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DownloadItem>) {
|
||||
_uiState.value = DownloadsUiState(
|
||||
items = items.sortedByDescending { it.updatedAtEpochMs },
|
||||
items = items,
|
||||
)
|
||||
notifyLiveStatusPlatform()
|
||||
}
|
||||
|
|
@ -378,7 +377,7 @@ private object DownloadsCodec {
|
|||
fun encodeItems(items: Collection<DownloadItem>): String =
|
||||
json.encodeToString(
|
||||
StoredDownloadsPayload(
|
||||
items = items.toList().sortedByDescending { it.updatedAtEpochMs },
|
||||
items = items.toList(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue