Comment cleanup

This commit is contained in:
Nicholas Wallace
2026-07-19 09:23:31 -07:00
parent 0df0a206e7
commit d27f431f6f
10 changed files with 32 additions and 45 deletions
@@ -98,7 +98,6 @@ class LocalLibraryItem(
return true
}
/** App-private files have paths; SAF files must be validated through their persisted URI. */
@JsonIgnore
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
return LocalFile("", null, contentUrl ?: "", "", path, "", null, 0).exists(ctx)
@@ -10,7 +10,7 @@ import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import java.io.File
/** Creates local-library records from the completed download manifest, not a recursive rescan. */
/** Creates local-library records from a completed download manifest. */
class FolderScanner(private val ctx: Context) {
private val tag = "FolderScanner"
@@ -19,9 +19,13 @@ class FolderScanner(private val ctx: Context) {
var localMediaProgress: LocalMediaProgress?
)
private fun localLibraryItemId(mediaItemId: String) = "local_${DeviceManager.getBase64Id(mediaItemId)}"
private fun localLibraryItemId(mediaItemId: String) =
"local_${DeviceManager.getBase64Id(mediaItemId)}"
private fun createLocalFile(part: DownloadItemPart, externalFile: DocumentFile? = null): LocalFile? {
private fun createLocalFile(
part: DownloadItemPart,
externalFile: DocumentFile? = null
): LocalFile? {
if (part.isInternalStorage) {
val file = File(part.finalDestinationPath)
if (!file.exists()) return null
@@ -43,7 +47,8 @@ class FolderScanner(private val ctx: Context) {
try {
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
descriptor.statSize.coerceAtLeast(0L)
} ?: 0L
}
?: 0L
} catch (e: Exception) {
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
return null
@@ -123,8 +128,7 @@ class FolderScanner(private val ctx: Context) {
if (part.isInternalStorage) {
null
} else {
part.completedDestinationUri
?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
?: resolveExternalFile(externalFolder, part)
}
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
@@ -203,7 +207,9 @@ class FolderScanner(private val ctx: Context) {
val result = DownloadItemScanResult(localItem, null)
item.userMediaProgress?.let { progress ->
val progressId = if (item.episodeId.isNullOrEmpty()) localItem.id else "${localItem.id}-$localEpisodeId"
val progressId =
if (item.episodeId.isNullOrEmpty()) localItem.id
else "${localItem.id}-$localEpisodeId"
result.localMediaProgress =
LocalMediaProgress(
progressId,
@@ -247,14 +253,18 @@ class FolderScanner(private val ctx: Context) {
*/
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
if (folder == null) return null
folder.findFile(part.filename)?.let { return it }
folder.findFile(part.filename)?.let {
return it
}
val expectedBaseName = part.filename.substringBeforeLast('.')
return folder.listFiles().firstOrNull { document ->
document.name == part.filename ||
document.fullName == part.filename ||
(part.audioTrack != null && document.isFile &&
(part.audioTrack != null &&
document.isFile &&
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
(part.audioTrack != null && document.isFile &&
(part.audioTrack != null &&
document.isFile &&
document.fullName.substringBeforeLast('.') == expectedBaseName)
}
}
@@ -123,9 +123,9 @@ class DbManager {
}
/**
* The downloader now persists app-owned staging paths instead of DownloadManager state. Old
* queue entries cannot be resumed safely, but completed local media lives in other books and is
* deliberately left alone.
* Removes DownloadManager queue entries that cannot be resumed by the app-managed downloader.
*
* This migration preserves completed local media, which is stored in separate books.
*/
fun clearLegacyDownloadQueueOnce() {
val metadata = Paper.book("downloadQueueMetadata")
@@ -24,10 +24,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import okhttp3.Call
/**
* Process-owned Android download queue. Every network write goes through app-owned staging and is
* admitted only after reserving enough space for the complete operation.
*/
/** Manages the process-owned queue for app-managed downloads. */
class DownloadItemManager(
private val folderScanner: FolderScanner,
private val context: Context,
@@ -36,7 +33,6 @@ class DownloadItemManager(
private val tag = "DownloadItemManager"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeCalls = ConcurrentHashMap<String, Call>()
/** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */
private val safFolderLocks = ConcurrentHashMap<String, Any>()
private val reservations = mutableMapOf<String, Long>()
private val lastPersistTime = mutableMapOf<String, Long>()
@@ -366,8 +362,6 @@ class DownloadItemManager(
}
private fun tryReserve(part: DownloadItemPart): Boolean {
// Covers from older servers often omit a size. Keep unknown-length work serial and use the
// runtime low-space guard rather than leaving those queue items permanently deferred.
if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
val staging = File(part.destinationPath)
staging.parentFile?.mkdirs()
@@ -14,7 +14,7 @@ import com.audiobookshelf.app.models.DownloadItem
import java.io.File
import java.util.concurrent.TimeUnit
/** Removes only terminally failed download items after their retention window has elapsed. */
/** Removes terminally failed downloads after their retention window elapses. */
object IncompleteDownloadCleanup {
private const val tag = "IncompleteDownloadCleanup"
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
@@ -34,7 +34,7 @@ object IncompleteDownloadCleanup {
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
}
/** Called on app/service startup as a catch-up for work delayed by Android or force-stop. */
/** Removes failures retained longer than 24 hours when scheduled work did not run. */
fun cleanupExpired(context: Context): Set<String> {
val now = System.currentTimeMillis()
return DeviceManager.dbManager.getDownloadItems()
@@ -49,7 +49,6 @@ object IncompleteDownloadCleanup {
private fun isEligible(item: DownloadItem, now: Long): Boolean {
val failedAt = item.terminalFailureAt ?: return false
if (now - failedAt < RETENTION_MS) return false
// Do not remove an item while another part is still downloading, waiting, or finalizing.
return item.downloadItemParts.all { part ->
part.moved || (part.failed && !part.isMoving)
}
@@ -61,7 +60,6 @@ object IncompleteDownloadCleanup {
if (part.isInternalStorage && part.moved) {
deleteAppOwnedFile(context, File(part.finalDestinationPath))
} else if (!part.isInternalStorage && part.moved) {
// Never infer an arbitrary SAF path during cleanup. The stored document URI is authoritative.
part.completedDestinationUri?.let { uriString ->
try {
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
@@ -20,8 +20,10 @@ class InternalDownloadManager(
) {
private val tag = "InternalDownloadManager"
/**
* Returns the active call so the queue can cancel a stalled transfer. A partial staging file is
* retained only when the server proves that it honoured a subsequent range request.
* Starts or resumes a download.
*
* @param url authenticated download URL
* @return active call, used to cancel a stalled transfer
*/
fun download(url: String): Call {
destinationFile.parentFile?.mkdirs()
@@ -15,7 +15,6 @@ data class DownloadItemPart(
val downloadItemId: String,
val filename: String,
val fileSize: Long,
/** App-owned staging location. This is intentionally a String so it survives process storage. */
@JsonIgnore val destinationPath: String,
val finalDestinationPath:String,
val serverPath: String,
@@ -32,15 +31,12 @@ data class DownloadItemPart(
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
/** Final SAF document returned by the provider after a successful move. */
/** Persisted Android-only SAF URI used to reopen a completed document after process recovery. */
@JsonIgnore var completedDestinationUri: String?,
val finalDestinationSubfolder: String,
var downloadId: Long?,
@JsonIgnore var lastUpdateTime: Long?,
var progress: Long,
var bytesDownloaded: Long,
/** Android queue state; hidden from the shared Capacitor download-part payload. */
@JsonIgnore var retryCount: Int = 0,
@JsonIgnore var waitingForSpace: Boolean = false
) {
@@ -5,7 +5,6 @@ import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler
@@ -27,7 +26,6 @@ class AbsDownloader : Plugin() {
lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
lateinit var downloadItemManager: DownloadItemManager
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
@@ -45,7 +43,6 @@ class AbsDownloader : Plugin() {
override fun load() {
mainActivity = (activity as MainActivity)
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadServiceHost.ensure(mainActivity)
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
@@ -56,10 +53,7 @@ class AbsDownloader : Plugin() {
super.handleOnDestroy()
}
/**
* Queue restoration happens before the WebView mounts. Replay its parent items when Vue registers
* the listener so subsequent part updates always have a matching store entry.
*/
/** Replays restored queue items when the frontend subscribes to download events. */
@PluginMethod(returnType = PluginMethod.RETURN_NONE)
override fun addListener(call: PluginCall) {
super.addListener(call)
@@ -151,8 +145,6 @@ class AbsDownloader : Plugin() {
val isInternal = localFolder.id.startsWith("internal-")
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}"
// Keep internal staging on the same filesystem as its final file so finalization is a rename.
// External-folder downloads can use app external storage and are moved through SAF afterwards.
val tempFolderPath =
if (isInternal) {
"${mainActivity.filesDir}/download-staging/${libraryItem.id}"
@@ -14,8 +14,6 @@ import com.audiobookshelf.app.models.DownloadItemPart
/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
class DownloadService : Service() {
private var lastPart: DownloadItemPart? = null
override fun onCreate() {
super.onCreate()
createChannel()
@@ -40,7 +38,6 @@ class DownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
fun onPartUpdate(part: DownloadItemPart) {
lastPart = part
val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}"
val progress = part.progress.coerceIn(0L, 100L).toInt()
val notification = notification(text, progress, part.fileSize > 0L)
@@ -28,10 +28,9 @@ object DownloadServiceHost {
return manager!!
}
/** Attaches the frontend after restored queue items have been emitted. */
@Synchronized
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) {
// Rehydrate the frontend's parent items before allowing part-progress events through.
// Otherwise a running restored queue can emit a part before Vue knows its DownloadItem.
bridgeReady = false
bridgeEmitter = emitter
val queue = ensure(context)