mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-29 22:57:16 +02:00
Comment cleanup
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
+2
-4
@@ -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()
|
||||||
|
|||||||
+4
-2
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user