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 return true
} }
/** App-private files have paths; SAF files must be validated through their persisted URI. */
@JsonIgnore @JsonIgnore
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean { private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
return LocalFile("", null, contentUrl ?: "", "", path, "", null, 0).exists(ctx) 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 com.audiobookshelf.app.models.DownloadItemPart
import java.io.File 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) { class FolderScanner(private val ctx: Context) {
private val tag = "FolderScanner" private val tag = "FolderScanner"
@@ -19,9 +19,13 @@ class FolderScanner(private val ctx: Context) {
var localMediaProgress: LocalMediaProgress? 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) { if (part.isInternalStorage) {
val file = File(part.finalDestinationPath) val file = File(part.finalDestinationPath)
if (!file.exists()) return null if (!file.exists()) return null
@@ -43,7 +47,8 @@ class FolderScanner(private val ctx: Context) {
try { try {
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
descriptor.statSize.coerceAtLeast(0L) descriptor.statSize.coerceAtLeast(0L)
} ?: 0L }
?: 0L
} catch (e: Exception) { } catch (e: Exception) {
Log.e(tag, "Could not open completed SAF file: $contentUrl", e) Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
return null return null
@@ -123,8 +128,7 @@ class FolderScanner(private val ctx: Context) {
if (part.isInternalStorage) { if (part.isInternalStorage) {
null null
} else { } else {
part.completedDestinationUri part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
?: resolveExternalFile(externalFolder, part) ?: resolveExternalFile(externalFolder, part)
} }
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}") 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) val result = DownloadItemScanResult(localItem, null)
item.userMediaProgress?.let { progress -> 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 = result.localMediaProgress =
LocalMediaProgress( LocalMediaProgress(
progressId, progressId,
@@ -247,14 +253,18 @@ class FolderScanner(private val ctx: Context) {
*/ */
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? { private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
if (folder == null) return null if (folder == null) return null
folder.findFile(part.filename)?.let { return it } folder.findFile(part.filename)?.let {
return it
}
val expectedBaseName = part.filename.substringBeforeLast('.') val expectedBaseName = part.filename.substringBeforeLast('.')
return folder.listFiles().firstOrNull { document -> return folder.listFiles().firstOrNull { document ->
document.name == part.filename || document.name == part.filename ||
document.fullName == part.filename || document.fullName == part.filename ||
(part.audioTrack != null && document.isFile && (part.audioTrack != null &&
document.isFile &&
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) || (document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
(part.audioTrack != null && document.isFile && (part.audioTrack != null &&
document.isFile &&
document.fullName.substringBeforeLast('.') == expectedBaseName) document.fullName.substringBeforeLast('.') == expectedBaseName)
} }
} }
@@ -123,9 +123,9 @@ class DbManager {
} }
/** /**
* The downloader now persists app-owned staging paths instead of DownloadManager state. Old * Removes DownloadManager queue entries that cannot be resumed by the app-managed downloader.
* queue entries cannot be resumed safely, but completed local media lives in other books and is *
* deliberately left alone. * This migration preserves completed local media, which is stored in separate books.
*/ */
fun clearLegacyDownloadQueueOnce() { fun clearLegacyDownloadQueueOnce() {
val metadata = Paper.book("downloadQueueMetadata") val metadata = Paper.book("downloadQueueMetadata")
@@ -24,10 +24,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Call import okhttp3.Call
/** /** Manages the process-owned queue for app-managed downloads. */
* 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.
*/
class DownloadItemManager( class DownloadItemManager(
private val folderScanner: FolderScanner, private val folderScanner: FolderScanner,
private val context: Context, private val context: Context,
@@ -36,7 +33,6 @@ class DownloadItemManager(
private val tag = "DownloadItemManager" private val tag = "DownloadItemManager"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val activeCalls = ConcurrentHashMap<String, Call>() private val activeCalls = ConcurrentHashMap<String, Call>()
/** DocumentsProvider does not make concurrent createDirectory/findFile calls atomic. */
private val safFolderLocks = ConcurrentHashMap<String, Any>() private val safFolderLocks = ConcurrentHashMap<String, Any>()
private val reservations = mutableMapOf<String, Long>() private val reservations = mutableMapOf<String, Long>()
private val lastPersistTime = mutableMapOf<String, Long>() private val lastPersistTime = mutableMapOf<String, Long>()
@@ -366,8 +362,6 @@ class DownloadItemManager(
} }
private fun tryReserve(part: DownloadItemPart): Boolean { 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 if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false
val staging = File(part.destinationPath) val staging = File(part.destinationPath)
staging.parentFile?.mkdirs() staging.parentFile?.mkdirs()
@@ -14,7 +14,7 @@ import com.audiobookshelf.app.models.DownloadItem
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit 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 { object IncompleteDownloadCleanup {
private const val tag = "IncompleteDownloadCleanup" private const val tag = "IncompleteDownloadCleanup"
private const val RETENTION_MS = 24L * 60L * 60L * 1000L private const val RETENTION_MS = 24L * 60L * 60L * 1000L
@@ -34,7 +34,7 @@ object IncompleteDownloadCleanup {
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId) 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> { fun cleanupExpired(context: Context): Set<String> {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
return DeviceManager.dbManager.getDownloadItems() return DeviceManager.dbManager.getDownloadItems()
@@ -49,7 +49,6 @@ object IncompleteDownloadCleanup {
private fun isEligible(item: DownloadItem, now: Long): Boolean { private fun isEligible(item: DownloadItem, now: Long): Boolean {
val failedAt = item.terminalFailureAt ?: return false val failedAt = item.terminalFailureAt ?: return false
if (now - failedAt < RETENTION_MS) 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 -> return item.downloadItemParts.all { part ->
part.moved || (part.failed && !part.isMoving) part.moved || (part.failed && !part.isMoving)
} }
@@ -61,7 +60,6 @@ object IncompleteDownloadCleanup {
if (part.isInternalStorage && part.moved) { if (part.isInternalStorage && part.moved) {
deleteAppOwnedFile(context, File(part.finalDestinationPath)) deleteAppOwnedFile(context, File(part.finalDestinationPath))
} else if (!part.isInternalStorage && part.moved) { } else if (!part.isInternalStorage && part.moved) {
// Never infer an arbitrary SAF path during cleanup. The stored document URI is authoritative.
part.completedDestinationUri?.let { uriString -> part.completedDestinationUri?.let { uriString ->
try { try {
DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete() DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete()
@@ -20,8 +20,10 @@ class InternalDownloadManager(
) { ) {
private val tag = "InternalDownloadManager" private val tag = "InternalDownloadManager"
/** /**
* Returns the active call so the queue can cancel a stalled transfer. A partial staging file is * Starts or resumes a download.
* retained only when the server proves that it honoured a subsequent range request. *
* @param url authenticated download URL
* @return active call, used to cancel a stalled transfer
*/ */
fun download(url: String): Call { fun download(url: String): Call {
destinationFile.parentFile?.mkdirs() destinationFile.parentFile?.mkdirs()
@@ -15,7 +15,6 @@ data class DownloadItemPart(
val downloadItemId: String, val downloadItemId: String,
val filename: String, val filename: String,
val fileSize: Long, val fileSize: Long,
/** App-owned staging location. This is intentionally a String so it survives process storage. */
@JsonIgnore val destinationPath: String, @JsonIgnore val destinationPath: String,
val finalDestinationPath:String, val finalDestinationPath:String,
val serverPath: String, val serverPath: String,
@@ -32,15 +31,12 @@ data class DownloadItemPart(
@JsonIgnore val uri: Uri, @JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri, @JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: 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?, @JsonIgnore var completedDestinationUri: String?,
val finalDestinationSubfolder: String, val finalDestinationSubfolder: String,
var downloadId: Long?, var downloadId: Long?,
@JsonIgnore var lastUpdateTime: Long?, @JsonIgnore var lastUpdateTime: Long?,
var progress: Long, var progress: Long,
var bytesDownloaded: Long, var bytesDownloaded: Long,
/** Android queue state; hidden from the shared Capacitor download-part payload. */
@JsonIgnore var retryCount: Int = 0, @JsonIgnore var retryCount: Int = 0,
@JsonIgnore var waitingForSpace: Boolean = false @JsonIgnore var waitingForSpace: Boolean = false
) { ) {
@@ -5,7 +5,6 @@ import android.util.Log
import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.* import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler import com.audiobookshelf.app.server.ApiHandler
@@ -27,7 +26,6 @@ class AbsDownloader : Plugin() {
lateinit var mainActivity: MainActivity lateinit var mainActivity: MainActivity
lateinit var apiHandler: ApiHandler lateinit var apiHandler: ApiHandler
lateinit var folderScanner: FolderScanner
lateinit var downloadItemManager: DownloadItemManager lateinit var downloadItemManager: DownloadItemManager
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
@@ -45,7 +43,6 @@ class AbsDownloader : Plugin() {
override fun load() { override fun load() {
mainActivity = (activity as MainActivity) mainActivity = (activity as MainActivity)
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity) apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadServiceHost.ensure(mainActivity) downloadItemManager = DownloadServiceHost.ensure(mainActivity)
DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter) DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter)
@@ -56,10 +53,7 @@ class AbsDownloader : Plugin() {
super.handleOnDestroy() super.handleOnDestroy()
} }
/** /** Replays restored queue items when the frontend subscribes to download events. */
* 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.
*/
@PluginMethod(returnType = PluginMethod.RETURN_NONE) @PluginMethod(returnType = PluginMethod.RETURN_NONE)
override fun addListener(call: PluginCall) { override fun addListener(call: PluginCall) {
super.addListener(call) super.addListener(call)
@@ -151,8 +145,6 @@ class AbsDownloader : Plugin() {
val isInternal = localFolder.id.startsWith("internal-") val isInternal = localFolder.id.startsWith("internal-")
val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" 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 = val tempFolderPath =
if (isInternal) { if (isInternal) {
"${mainActivity.filesDir}/download-staging/${libraryItem.id}" "${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. */ /** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */
class DownloadService : Service() { class DownloadService : Service() {
private var lastPart: DownloadItemPart? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
createChannel() createChannel()
@@ -40,7 +38,6 @@ class DownloadService : Service() {
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
fun onPartUpdate(part: DownloadItemPart) { fun onPartUpdate(part: DownloadItemPart) {
lastPart = part
val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}" val text = if (part.waitingForSpace) "Waiting for available storage" else "Downloading ${part.filename}"
val progress = part.progress.coerceIn(0L, 100L).toInt() val progress = part.progress.coerceIn(0L, 100L).toInt()
val notification = notification(text, progress, part.fileSize > 0L) val notification = notification(text, progress, part.fileSize > 0L)
@@ -28,10 +28,9 @@ object DownloadServiceHost {
return manager!! return manager!!
} }
/** Attaches the frontend after restored queue items have been emitted. */
@Synchronized @Synchronized
fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { 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 bridgeReady = false
bridgeEmitter = emitter bridgeEmitter = emitter
val queue = ensure(context) val queue = ensure(context)