diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ed8d66ca..1d275211 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -72,10 +72,10 @@ body: description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* multiple: true options: + - 'Android App - 0.13.0' + - 'iOS App - 0.13.0' - 'Android App - 0.12.0' - 'iOS App - 0.12.0' - - 'Android App - 0.11.0' - - 'iOS App - 0.11.0' validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 77d6cf44..bdc2dd4e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -43,10 +43,10 @@ body: description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.* multiple: true options: + - 'Android App - 0.13.0' + - 'iOS App - 0.13.0' - 'Android App - 0.12.0' - 'iOS App - 0.12.0' - - 'Android App - 0.11.0' - - 'iOS App - 0.11.0' validations: required: true - type: textarea diff --git a/android/app/build.gradle b/android/app/build.gradle index 71becb7c..979655cc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,8 +36,8 @@ android { applicationId "com.audiobookshelf.app" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 116 - versionName "0.12.0-beta" + versionCode 117 + versionName "0.13.0-beta" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders = [ "appAuthRedirectScheme": "com.audiobookshelf.app" @@ -92,6 +92,7 @@ dependencies { implementation project(':capacitor-cordova-android-plugins') implementation "androidx.core:core-ktx:$androidx_core_ktx_version" + implementation "androidx.work:work-runtime-ktx:2.9.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 67a6f3c5..3c7cadc6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,12 +6,14 @@ + + + + () + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + needed.add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + needed.add(Manifest.permission.POST_NOTIFICATIONS) + } + if (needed.isNotEmpty()) { + ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt index 76b8f4b4..fc9beb58 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -1,74 +1,102 @@ package com.audiobookshelf.app.data import android.content.Context +import android.net.Uri import android.support.v4.media.MediaDescriptionCompat import android.util.Log import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonIgnoreProperties import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonTypeInfo +import java.io.File enum class LockOrientationSetting { - NONE, PORTRAIT, LANDSCAPE + NONE, + PORTRAIT, + LANDSCAPE } enum class HapticFeedbackSetting { - OFF, LIGHT, MEDIUM, HEAVY + OFF, + LIGHT, + MEDIUM, + HEAVY } enum class ShakeSensitivitySetting { - VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH + VERY_LOW, + LOW, + MEDIUM, + HIGH, + VERY_HIGH } enum class DownloadUsingCellularSetting { - ASK, ALWAYS, NEVER + ASK, + ALWAYS, + NEVER } enum class StreamingUsingCellularSetting { - ASK, ALWAYS, NEVER + ASK, + ALWAYS, + NEVER } enum class AndroidAutoBrowseSeriesSequenceOrderSetting { - ASC, DESC + ASC, + DESC } @JsonIgnoreProperties(ignoreUnknown = true) data class ServerConnectionConfig( - var id:String, - var index:Int, - var name:String, - var address:String, - // version added after 0.9.81-beta - var version:String?, - var userId:String, - var username:String, - var token:String, - var customHeaders:Map? + var id: String, + var index: Int, + var name: String, + var address: String, + // version added after 0.9.81-beta + var version: String?, + var userId: String, + var username: String, + var token: String, + var customHeaders: Map? ) @JsonIgnoreProperties(ignoreUnknown = true) data class LocalFile( - var id:String, - var filename:String?, - var contentUrl:String, - var basePath:String, - var absolutePath:String, - var simplePath:String, - var mimeType:String?, - var size:Long + var id: String, + var filename: String?, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var mimeType: String?, + var size: Long ) { @JsonIgnore - fun isAudioFile():Boolean { + fun exists(ctx: Context): Boolean { + if (contentUrl.startsWith("content:")) { + return try { + ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false + } catch (e: Exception) { + Log.w("LocalFile", "Cannot access SAF file $contentUrl", e) + false + } + } + return File(absolutePath).exists() + } + + @JsonIgnore + fun isAudioFile(): Boolean { if (mimeType == "application/octet-stream") return true if (mimeType == "video/mp4") return true return mimeType?.startsWith("audio") == true } @JsonIgnore - fun isEBookFile():Boolean { + fun isEBookFile(): Boolean { return getEBookFormat() != null } @JsonIgnore - fun getEBookFormat():String? { + fun getEBookFormat(): String? { if (mimeType == "application/epub+zip") return "epub" if (mimeType == "application/pdf") return "pdf" if (mimeType == "application/x-mobipocket-ebook") return "mobi" @@ -81,118 +109,124 @@ data class LocalFile( @JsonIgnoreProperties(ignoreUnknown = true) data class LocalFolder( - var id:String, - var name:String, - var contentUrl:String, - var basePath:String, - var absolutePath:String, - var simplePath:String, - var storageType:String, - var mediaType:String + var id: String, + var name: String, + var contentUrl: String, + var basePath: String, + var absolutePath: String, + var storageType: String, + var mediaType: String ) -@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION) -@JsonSubTypes( - JsonSubTypes.Type(LibraryItem::class), - JsonSubTypes.Type(LocalLibraryItem::class) -) -open class LibraryItemWrapper(var id:String) { +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class)) +open class LibraryItemWrapper(var id: String) { @JsonIgnore - open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() } + open fun getMediaDescription( + progress: MediaProgressWrapper?, + ctx: Context + ): MediaDescriptionCompat { + return MediaDescriptionCompat.Builder().build() + } } @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceInfo( - var deviceId:String, - var manufacturer:String, - var model:String, - var sdkVersion:Int, - var clientVersion: String + var deviceId: String, + var manufacturer: String, + var model: String, + var sdkVersion: Int, + var clientVersion: String ) @JsonIgnoreProperties(ignoreUnknown = true) data class PlayItemRequestPayload( - var mediaPlayer:String, - var forceDirectPlay:Boolean, - var forceTranscode:Boolean, - var deviceInfo:DeviceInfo + var mediaPlayer: String, + var forceDirectPlay: Boolean, + var forceTranscode: Boolean, + var deviceInfo: DeviceInfo ) @JsonIgnoreProperties(ignoreUnknown = true) data class DeviceSettings( - var disableAutoRewind:Boolean, - var enableAltView:Boolean, - var allowSeekingOnMediaControls:Boolean, - var jumpBackwardsTime:Int, - var jumpForwardTime:Int, - var enableMp3IndexSeeking:Boolean, - var disableShakeToResetSleepTimer:Boolean, - var shakeSensitivity: ShakeSensitivitySetting, - var lockOrientation: LockOrientationSetting, - var hapticFeedback: HapticFeedbackSetting, - var autoSleepTimer: Boolean, - var autoSleepTimerStartTime: String, - var autoSleepTimerEndTime: String, - var autoSleepTimerAutoRewind: Boolean, - var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds - var sleepTimerLength: Long, // Time in milliseconds - var disableSleepTimerFadeOut: Boolean, - var disableSleepTimerResetFeedback: Boolean, - var enableSleepTimerAlmostDoneChime: Boolean, - var languageCode: String, - var downloadUsingCellular: DownloadUsingCellularSetting, - var streamingUsingCellular: StreamingUsingCellularSetting, - var androidAutoBrowseLimitForGrouping: Int, - var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting + var disableAutoRewind: Boolean, + var enableAltView: Boolean, + var allowSeekingOnMediaControls: Boolean, + var jumpBackwardsTime: Int, + var jumpForwardTime: Int, + var enableMp3IndexSeeking: Boolean, + var disableShakeToResetSleepTimer: Boolean, + var shakeSensitivity: ShakeSensitivitySetting, + var lockOrientation: LockOrientationSetting, + var hapticFeedback: HapticFeedbackSetting, + var autoSleepTimer: Boolean, + var autoSleepTimerStartTime: String, + var autoSleepTimerEndTime: String, + var autoSleepTimerAutoRewind: Boolean, + var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds + var sleepTimerLength: Long, // Time in milliseconds + var disableSleepTimerFadeOut: Boolean, + var disableSleepTimerResetFeedback: Boolean, + var enableSleepTimerAlmostDoneChime: Boolean, + var languageCode: String, + var downloadUsingCellular: DownloadUsingCellularSetting, + var streamingUsingCellular: StreamingUsingCellularSetting, + var androidAutoBrowseLimitForGrouping: Int, + var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting ) { companion object { // Static method to get default device settings - fun default():DeviceSettings { + fun default(): DeviceSettings { return DeviceSettings( - disableAutoRewind = false, - enableAltView = true, - allowSeekingOnMediaControls = false, - jumpBackwardsTime = 10, - jumpForwardTime = 10, - enableMp3IndexSeeking = false, - disableShakeToResetSleepTimer = false, - shakeSensitivity = ShakeSensitivitySetting.MEDIUM, - lockOrientation = LockOrientationSetting.NONE, - hapticFeedback = HapticFeedbackSetting.LIGHT, - autoSleepTimer = false, - autoSleepTimerStartTime = "22:00", - autoSleepTimerEndTime = "06:00", - sleepTimerLength = 900000L, // 15 minutes - autoSleepTimerAutoRewind = false, - autoSleepTimerAutoRewindTime = 300000L, // 5 minutes - disableSleepTimerFadeOut = false, - disableSleepTimerResetFeedback = false, - enableSleepTimerAlmostDoneChime = false, - languageCode = "en-us", - downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, - streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, - androidAutoBrowseLimitForGrouping = 100, - androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC + disableAutoRewind = false, + enableAltView = true, + allowSeekingOnMediaControls = false, + jumpBackwardsTime = 10, + jumpForwardTime = 10, + enableMp3IndexSeeking = false, + disableShakeToResetSleepTimer = false, + shakeSensitivity = ShakeSensitivitySetting.MEDIUM, + lockOrientation = LockOrientationSetting.NONE, + hapticFeedback = HapticFeedbackSetting.LIGHT, + autoSleepTimer = false, + autoSleepTimerStartTime = "22:00", + autoSleepTimerEndTime = "06:00", + sleepTimerLength = 900000L, // 15 minutes + autoSleepTimerAutoRewind = false, + autoSleepTimerAutoRewindTime = 300000L, // 5 minutes + disableSleepTimerFadeOut = false, + disableSleepTimerResetFeedback = false, + enableSleepTimerAlmostDoneChime = false, + languageCode = "en-us", + downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS, + streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS, + androidAutoBrowseLimitForGrouping = 100, + androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC ) } } @get:JsonIgnore - val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L + val jumpBackwardsTimeMs + get() = jumpBackwardsTime * 1000L @get:JsonIgnore - val jumpForwardTimeMs get() = jumpForwardTime * 1000L + val jumpForwardTimeMs + get() = jumpForwardTime * 1000L @get:JsonIgnore - val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt() + val autoSleepTimerStartHour + get() = autoSleepTimerStartTime.split(":")[0].toInt() @get:JsonIgnore - val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt() + val autoSleepTimerStartMinute + get() = autoSleepTimerStartTime.split(":")[1].toInt() @get:JsonIgnore - val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt() + val autoSleepTimerEndHour + get() = autoSleepTimerEndTime.split(":")[0].toInt() @get:JsonIgnore - val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt() - + val autoSleepTimerEndMinute + get() = autoSleepTimerEndTime.split(":")[1].toInt() @JsonIgnore - fun getShakeThresholdGravity() : Float { // Used in ShakeDetector + fun getShakeThresholdGravity(): Float { // Used in ShakeDetector return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f @@ -206,10 +240,10 @@ data class DeviceSettings( } data class DeviceData( - var serverConnectionConfigs:MutableList, - var lastServerConnectionConfigId:String?, - var deviceSettings: DeviceSettings?, - var lastPlaybackSession: PlaybackSession? + var serverConnectionConfigs: MutableList, + var lastServerConnectionConfigId: String?, + var deviceSettings: DeviceSettings?, + var lastPlaybackSession: PlaybackSession? ) { @JsonIgnore fun getLastServerConnectionConfig(): ServerConnectionConfig? { @@ -218,4 +252,3 @@ data class DeviceData( } } } - diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt b/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt deleted file mode 100644 index f4f706bf..00000000 --- a/android/app/src/main/java/com/audiobookshelf/app/data/FolderScanResult.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.audiobookshelf.app.data - -data class FolderScanResult( - var itemsAdded:Int, - var itemsUpdated:Int, - var itemsRemoved:Int, - var itemsUpToDate:Int, - val localFolder:LocalFolder, - val localLibraryItems:List, -) - -data class LocalLibraryItemScanResult( - val updated:Boolean, - val localLibraryItem:LocalLibraryItem, -) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt index 635c98f2..1236bc5c 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -80,7 +80,7 @@ class LocalLibraryItem( } @JsonIgnore - fun hasTracks(episode:PodcastEpisode?): Boolean { + fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean { var audioTracks = media.getAudioTracks() as MutableList if (episode != null) { // Get podcast episode audio track episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks } @@ -91,15 +91,18 @@ class LocalLibraryItem( if (it.metadata === null) { return false } - // Check that file exists - val file = File(it.metadata!!.path) - if (!file.exists()) { + if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) { return false } } return true } + @JsonIgnore + private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean { + return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx) + } + @JsonIgnore fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession { val localEpisodeId = episode?.id diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt index 987e5c88..11673e89 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -14,7 +14,6 @@ data class LocalMediaItem( var mediaType: String, var folderId: String, var contentUrl: String, - var simplePath: String, var basePath: String, var absolutePath: String, var audioTracks: MutableList, diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt index 951b6a3b..9051149f 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -7,570 +7,306 @@ import androidx.documentfile.provider.DocumentFile import com.anggrayudi.storage.file.* import com.audiobookshelf.app.data.* import com.audiobookshelf.app.models.DownloadItem -import com.fasterxml.jackson.core.json.JsonReadFeature -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.audiobookshelf.app.models.DownloadItemPart import java.io.File -class FolderScanner(var ctx: Context) { +/** Creates local-library records from a completed download manifest. */ +class FolderScanner(private val ctx: Context) { private val tag = "FolderScanner" - private var jacksonMapper = - jacksonObjectMapper() - .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) data class DownloadItemScanResult( val localLibraryItem: LocalLibraryItem, var localMediaProgress: LocalMediaProgress? ) - private fun getLocalLibraryItemId(mediaItemId: String): String { - return "local_" + DeviceManager.getBase64Id(mediaItemId) - } + private fun localLibraryItemId(mediaItemId: String) = + "local_${DeviceManager.getBase64Id(mediaItemId)}" - private fun scanInternalDownloadItem( - downloadItem: DownloadItem, - cb: (DownloadItemScanResult?) -> Unit - ) { - val localLibraryItemId = "local_${downloadItem.libraryItemId}" - - var localEpisodeId: String? = null - var localLibraryItem: LocalLibraryItem? - if (downloadItem.mediaType == "book") { - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - downloadItem.itemFolderPath, - downloadItem.itemFolderPath, - "", - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } else { - // Lookup or create podcast local library item - localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) - if (localLibraryItem == null) { - Log.d( - tag, - "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}" - ) - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - downloadItem.itemFolderPath, - downloadItem.itemFolderPath, - "", - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } + private fun createLocalFile( + part: DownloadItemPart, + externalFile: DocumentFile? = null + ): LocalFile? { + if (part.isInternalStorage) { + val file = File(part.finalDestinationPath) + if (!file.exists()) return null + return LocalFile( + DeviceManager.getBase64Id(file.name), + file.name, + Uri.fromFile(file).toString(), + file.getBasePath(ctx), + file.absolutePath, + file.mimeType, + file.length() + ) } - val audioTracks: MutableList = mutableListOf() - var foundEBookFile = false - - downloadItem.downloadItemParts.forEach { downloadItemPart -> - Log.d( - tag, - "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}" + part.completedDestinationUri?.let { contentUrl -> + val uri = Uri.parse(contentUrl) + val size = + try { + ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + descriptor.statSize.coerceAtLeast(0L) + } + ?: 0L + } catch (e: Exception) { + Log.e(tag, "Could not open completed SAF file: $contentUrl", e) + return null + } + // Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even + // though the URI remains readable. Keep the URI as the authoritative local-file location. + return LocalFile( + DeviceManager.getBase64Id(contentUrl), + part.filename, + contentUrl, + part.localFolderName, + part.finalDestinationPath, + mimeTypeFor(part), + size ) + } - val file = File(downloadItemPart.finalDestinationPath) - Log.d(tag, "Scan internal storage item created file ${file.name}") + // Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a + // file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted + // SAF tree grant instead. + val document = externalFile + if (document == null || !document.exists()) { + Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}") + return null + } + return LocalFile( + DeviceManager.getBase64Id(document.id), + document.name, + document.uri.toString(), + document.getBasePath(ctx), + document.getAbsolutePath(ctx), + document.mimeType, + document.length() + ) + } - if (file == null) { - Log.e( - tag, - "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}" - ) - } else { - if (downloadItemPart.audioTrack != null) { - val audioTrackFromServer = downloadItemPart.audioTrack - Log.d( - tag, - "scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}" + private fun newLocalLibraryItem( + id: String, + downloadItem: DownloadItem, + basePath: String, + absolutePath: String, + contentUrl: String + ) = + LocalLibraryItem( + id, + downloadItem.localFolder.id, + basePath, + absolutePath, + contentUrl, + false, + downloadItem.mediaType, + downloadItem.media.getLocalCopy(), + mutableListOf(), + null, + null, + true, + downloadItem.serverConnectionConfigId, + downloadItem.serverAddress, + downloadItem.serverUserId, + downloadItem.libraryItemId ) - val localFileId = DeviceManager.getBase64Id(file.name) - Log.d(tag, "Scan internal file localFileId=$localFileId") - val localFile = - LocalFile( - localFileId, - file.name, - downloadItemPart.finalDestinationUri.toString(), - file.getBasePath(ctx), - file.absolutePath, - file.getSimplePath(ctx), - file.mimeType, - file.length() - ) - localLibraryItem.localFiles.add(localFile) + private fun scanParts( + item: DownloadItem, + localItem: LocalLibraryItem, + externalFolder: DocumentFile? = null, + callback: (DownloadItemScanResult?) -> Unit + ) { + val tracks = mutableListOf() + var foundEbook = false + var localEpisodeId: String? = null - val trackFileMetadata = + item.downloadItemParts.forEach { part -> + val externalFile = + if (part.isInternalStorage) { + null + } else { + part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) } + ?: resolveExternalFile(externalFolder, part) + } + Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}") + val localFile = createLocalFile(part, externalFile) ?: return@forEach + when { + part.audioTrack != null -> { + val serverTrack = part.audioTrack + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + val metadata = FileMetadata( - file.name, - file.extension, - file.absolutePath, - file.getBasePath(ctx), - file.length() + localFile.filename ?: "", + File(localFile.filename ?: "").extension, + localFile.absolutePath, + localFile.basePath, + localFile.size ) - // Create new audio track val track = AudioTrack( - audioTrackFromServer.index, - audioTrackFromServer.startOffset, - audioTrackFromServer.duration, + serverTrack.index, + serverTrack.startOffset, + serverTrack.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", - trackFileMetadata, + metadata, true, - localFileId, - audioTrackFromServer.index + localFile.id, + serverTrack.index ) - audioTracks.add(track) - - Log.d( - tag, - "scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}" - ) - - // Add podcast episodes to library - downloadItemPart.episode?.let { podcastEpisode -> - val podcast = localLibraryItem.media as Podcast - val newEpisode = podcast.addEpisode(track, podcastEpisode) - localEpisodeId = newEpisode.id - Log.d( - tag, - "scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}" - ) + tracks.add(track) + Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})") + part.episode?.let { episode -> + val podcast = localItem.media as Podcast + localEpisodeId = podcast.addEpisode(track, episode).id } - } else if (downloadItemPart.ebookFile != null) { - foundEBookFile = true - Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}") - val localFileId = DeviceManager.getBase64Id(file.name) - val localFile = - LocalFile( - localFileId, - file.name, - Uri.fromFile(file).toString(), - file.getBasePath(ctx), - file.absolutePath, - file.getSimplePath(ctx), - file.mimeType, - file.length() - ) - localLibraryItem.localFiles.add(localFile) - - val ebookFile = + } + part.ebookFile != null -> { + foundEbook = true + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) + (localItem.media as Book).ebookFile = EBookFile( - downloadItemPart.ebookFile.ino, - downloadItemPart.ebookFile.metadata, - downloadItemPart.ebookFile.ebookFormat, + part.ebookFile.ino, + part.ebookFile.metadata, + part.ebookFile.ebookFormat, true, - localFileId, + localFile.id, localFile.contentUrl ) - (localLibraryItem.media as Book).ebookFile = ebookFile - Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}") - } else { - val localFileId = DeviceManager.getBase64Id(file.name) - val localFile = - LocalFile( - localFileId, - file.name, - Uri.fromFile(file).toString(), - file.getBasePath(ctx), - file.absolutePath, - file.getSimplePath(ctx), - file.mimeType, - file.length() - ) - - localLibraryItem.coverAbsolutePath = localFile.absolutePath - localLibraryItem.coverContentUrl = localFile.contentUrl - localLibraryItem.localFiles.add(localFile) + } + else -> { + localItem.coverAbsolutePath = localFile.absolutePath + localItem.coverContentUrl = localFile.contentUrl + localItem.localFiles.removeAll { it.id == localFile.id } + localItem.localFiles.add(localFile) } } } - if (audioTracks.isEmpty() && !foundEBookFile) { - Log.d( - tag, - "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}" - ) - return cb(null) + if (tracks.isEmpty() && !foundEbook) { + callback(null) + return } - - // For books sort audio tracks then set - if (downloadItem.mediaType == "book") { - audioTracks.sortBy { it.index } - - var indexCheck = 1 - var startOffset = 0.0 - audioTracks.forEach { audioTrack -> - if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) { - audioTrack.index = indexCheck - audioTrack.startOffset = startOffset - } - indexCheck++ - startOffset += audioTrack.duration + if (item.mediaType == "book") { + tracks.sortBy { it.index } + var expectedIndex = 1 + var offset = 0.0 + tracks.forEach { track -> + track.index = expectedIndex++ + track.startOffset = offset + offset += track.duration } - - localLibraryItem.media.setAudioTracks(audioTracks) + localItem.media.setAudioTracks(tracks) } - val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null) - - // If library item had media progress then make local media progress and save - downloadItem.userMediaProgress?.let { mediaProgress -> - val localMediaProgressId = - if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId - else "$localLibraryItemId-$localEpisodeId" - val newLocalMediaProgress = + val result = DownloadItemScanResult(localItem, null) + item.userMediaProgress?.let { progress -> + val progressId = + if (item.episodeId.isNullOrEmpty()) localItem.id + else "${localItem.id}-$localEpisodeId" + result.localMediaProgress = LocalMediaProgress( - id = localMediaProgressId, - localLibraryItemId = localLibraryItemId, - localEpisodeId = localEpisodeId, - duration = mediaProgress.duration, - progress = mediaProgress.progress, - currentTime = mediaProgress.currentTime, - isFinished = mediaProgress.isFinished, - ebookLocation = mediaProgress.ebookLocation, - ebookProgress = mediaProgress.ebookProgress, - lastUpdate = mediaProgress.lastUpdate, - startedAt = mediaProgress.startedAt, - finishedAt = mediaProgress.finishedAt, - serverConnectionConfigId = downloadItem.serverConnectionConfigId, - serverAddress = downloadItem.serverAddress, - serverUserId = downloadItem.serverUserId, - libraryItemId = downloadItem.libraryItemId, - episodeId = downloadItem.episodeId + progressId, + localItem.id, + localEpisodeId, + progress.duration, + progress.progress, + progress.currentTime, + progress.isFinished, + progress.ebookLocation, + progress.ebookProgress, + progress.lastUpdate, + progress.startedAt, + progress.finishedAt, + item.serverConnectionConfigId, + item.serverAddress, + item.serverUserId, + item.libraryItemId, + item.episodeId ) - Log.d( - tag, - "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}" - ) - DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress) - - downloadItemScanResult.localMediaProgress = newLocalMediaProgress + DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!) } - - DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) - - cb(downloadItemScanResult) + DeviceManager.dbManager.saveLocalLibraryItem(localItem) + callback(result) } - // Scan item after download and create local library item - fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) { - // If downloading to internal storage handle separately - if (downloadItem.isInternalStorage) { - scanInternalDownloadItem(downloadItem, cb) + private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? { + if (subPath.isBlank()) return root + var current = root + subPath.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + current = current.findFile(segment) ?: return null + } + return current + } + + /** + * DownloadsProvider on Android 10 may expose an audio document without its extension through + * DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base + * filename. MIME type and server-reported size are unreliable for Opus on this platform. + */ + private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? { + if (folder == null) return null + 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 && + (document.name ?: "").substringBeforeLast('.') == expectedBaseName) || + (part.audioTrack != null && + document.isFile && + document.fullName.substringBeforeLast('.') == expectedBaseName) + } + } + + private fun mimeTypeFor(part: DownloadItemPart): String? { + return part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" + } + } + + fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) { + if (item.isInternalStorage) { + val id = "local_${item.libraryItemId}" + val localItem = + DeviceManager.dbManager.getLocalLibraryItem(id) + ?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "") + scanParts(item, localItem, callback = callback) return } - val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl)) - val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf() - - var itemFolderId = "" - var itemFolderUrl = "" - var itemFolderBasePath = "" - var itemFolderAbsolutePath = "" - foldersFound.forEach { - // e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game" - // and itemSubfolder is "Orson Scott Card/Enders Game" - if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) { - itemFolderId = it.id - itemFolderUrl = it.uri.toString() - itemFolderBasePath = it.getBasePath(ctx) - itemFolderAbsolutePath = it.getAbsolutePath(ctx) - } + val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl)) + if (root == null) { + Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}") + callback(null) + return } - - if (itemFolderUrl == "") { - Log.d(tag, "scanDownloadItem failed to find media folder") - return cb(null) + val itemFolder = findFolderByPath(root, item.itemSubfolder) + if (itemFolder == null) { + Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}") + callback(null) + return } - val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl)) - - if (df == null) { - Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}") - return cb(null) - } - - val localLibraryItemId = getLocalLibraryItemId(itemFolderId) - Log.d( - tag, - "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId" - ) - - // Search for files in media item folder - // m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154 - val filesFound = - df.search( - false, - DocumentFileType.FILE, - arrayOf("audio/*", "image/*", "video/mp4", "application/*") - ) - Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}") - - var localEpisodeId: String? = null - var localLibraryItem: LocalLibraryItem? - if (downloadItem.mediaType == "book") { - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - itemFolderBasePath, - itemFolderAbsolutePath, - itemFolderUrl, - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } else { - // Lookup or create podcast local library item - localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) - if (localLibraryItem == null) { - Log.d( - tag, - "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}" - ) - localLibraryItem = - LocalLibraryItem( - localLibraryItemId, - downloadItem.localFolder.id, - itemFolderBasePath, - itemFolderAbsolutePath, - itemFolderUrl, - false, - downloadItem.mediaType, - downloadItem.media.getLocalCopy(), - mutableListOf(), - null, - null, - true, - downloadItem.serverConnectionConfigId, - downloadItem.serverAddress, - downloadItem.serverUserId, - downloadItem.libraryItemId - ) - } - } - - val audioTracks: MutableList = mutableListOf() - var foundEBookFile = false - - filesFound.forEach { docFile -> - val itemPart = - downloadItem.downloadItemParts.find { itemPart -> itemPart.filename == docFile.name } - if (itemPart == null) { - if (downloadItem.mediaType == "book" - ) { // for books every download item should be a file found - Log.e( - tag, - "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}" - ) - } - } else if (itemPart.audioTrack != null) { // Is audio track - val audioTrackFromServer = itemPart.audioTrack - Log.d( - tag, - "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}" - ) - - val localFileId = DeviceManager.getBase64Id(docFile.id) - val localFile = - LocalFile( - localFileId, - docFile.name, - docFile.uri.toString(), - docFile.getBasePath(ctx), - docFile.getAbsolutePath(ctx), - docFile.getSimplePath(ctx), - docFile.mimeType, - docFile.length() - ) - localLibraryItem.localFiles.add(localFile) - - // Create new audio track - val trackFileMetadata = - FileMetadata( - docFile.name ?: "", - docFile.extension ?: "", - docFile.getAbsolutePath(ctx), - docFile.getBasePath(ctx), - docFile.length() - ) - val track = - AudioTrack( - audioTrackFromServer.index, - audioTrackFromServer.startOffset, - audioTrackFromServer.duration, - localFile.filename ?: "", - localFile.contentUrl, - localFile.mimeType ?: "", - trackFileMetadata, - true, - localFileId, - audioTrackFromServer.index - ) - audioTracks.add(track) - - Log.d( - tag, - "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}" - ) - - // Add podcast episodes to library - itemPart.episode?.let { podcastEpisode -> - val podcast = localLibraryItem.media as Podcast - val newEpisode = podcast.addEpisode(track, podcastEpisode) - localEpisodeId = newEpisode.id - Log.d( - tag, - "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}" - ) - } - } else if (itemPart.ebookFile != null) { // Ebook - foundEBookFile = true - Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}") - val localFileId = DeviceManager.getBase64Id(docFile.id) - val localFile = - LocalFile( - localFileId, - docFile.name, - docFile.uri.toString(), - docFile.getBasePath(ctx), - docFile.getAbsolutePath(ctx), - docFile.getSimplePath(ctx), - docFile.mimeType, - docFile.length() - ) - localLibraryItem.localFiles.add(localFile) - - val ebookFile = - EBookFile( - itemPart.ebookFile.ino, - itemPart.ebookFile.metadata, - itemPart.ebookFile.ebookFormat, - true, - localFileId, - localFile.contentUrl - ) - (localLibraryItem.media as Book).ebookFile = ebookFile - Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}") - } else { // Cover image - val localFileId = DeviceManager.getBase64Id(docFile.id) - val localFile = - LocalFile( - localFileId, - docFile.name, - docFile.uri.toString(), - docFile.getBasePath(ctx), - docFile.getAbsolutePath(ctx), - docFile.getSimplePath(ctx), - docFile.mimeType, - docFile.length() - ) - - localLibraryItem.coverAbsolutePath = localFile.absolutePath - localLibraryItem.coverContentUrl = localFile.contentUrl - localLibraryItem.localFiles.add(localFile) - } - } - - if (audioTracks.isEmpty() && !foundEBookFile) { - Log.d( - tag, - "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}" - ) - return cb(null) - } - - // For books sort audio tracks then set - if (downloadItem.mediaType == "book") { - audioTracks.sortBy { it.index } - - var indexCheck = 1 - var startOffset = 0.0 - audioTracks.forEach { audioTrack -> - if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) { - audioTrack.index = indexCheck - audioTrack.startOffset = startOffset - } - indexCheck++ - startOffset += audioTrack.duration - } - - localLibraryItem.media.setAudioTracks(audioTracks) - } - - val downloadItemScanResult = DownloadItemScanResult(localLibraryItem, null) - - // If library item had media progress then make local media progress and save - downloadItem.userMediaProgress?.let { mediaProgress -> - val localMediaProgressId = - if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId - else "$localLibraryItemId-$localEpisodeId" - val newLocalMediaProgress = - LocalMediaProgress( - id = localMediaProgressId, - localLibraryItemId = localLibraryItemId, - localEpisodeId = localEpisodeId, - duration = mediaProgress.duration, - progress = mediaProgress.progress, - currentTime = mediaProgress.currentTime, - isFinished = mediaProgress.isFinished, - ebookLocation = mediaProgress.ebookLocation, - ebookProgress = mediaProgress.ebookProgress, - lastUpdate = mediaProgress.lastUpdate, - startedAt = mediaProgress.startedAt, - finishedAt = mediaProgress.finishedAt, - serverConnectionConfigId = downloadItem.serverConnectionConfigId, - serverAddress = downloadItem.serverAddress, - serverUserId = downloadItem.serverUserId, - libraryItemId = downloadItem.libraryItemId, - episodeId = downloadItem.episodeId - ) - Log.d( - tag, - "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}" - ) - - DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress) - - downloadItemScanResult.localMediaProgress = newLocalMediaProgress - } - - DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) - - cb(downloadItemScanResult) + val id = localLibraryItemId(itemFolder.id) + val localItem = + DeviceManager.dbManager.getLocalLibraryItem(id) + ?: newLocalLibraryItem( + id, + item, + itemFolder.getBasePath(ctx), + itemFolder.getAbsolutePath(ctx), + itemFolder.uri.toString() + ) + scanParts(item, localItem, itemFolder, callback) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt index b2f8293e..63c97a7c 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt @@ -7,7 +7,6 @@ import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.plugins.AbsLog import com.audiobookshelf.app.plugins.AbsLogger import io.paperdb.Paper -import java.io.File class DbManager { val tag = "DbManager" @@ -148,7 +147,7 @@ class DbManager { } // Make sure all local file ids still exist - fun cleanLocalLibraryItems() { + fun cleanLocalLibraryItems(context: Context) { val localLibraryItems = getLocalLibraryItems() localLibraryItems.forEach { lli -> @@ -157,15 +156,15 @@ class DbManager { // Check local files lli.localFiles = lli.localFiles.filter { localFile -> - val file = File(localFile.absolutePath) - if (!file.exists()) { + val exists = localFile.exists(context) + if (!exists) { Log.d( tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}" ) hasUpdates = true } - file.exists() + exists } as MutableList @@ -203,9 +202,11 @@ class DbManager { // Check cover still there lli.coverAbsolutePath?.let { - val coverFile = File(it) - - if (!coverFile.exists()) { + val coverExists = + lli.localFiles.any { localFile -> + localFile.absolutePath == it && localFile.exists(context) + } + if (!coverExists) { Log.d( tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}" @@ -290,15 +291,13 @@ class DbManager { return sessions } - fun saveLog(log:AbsLog) { + fun saveLog(log: AbsLog) { Paper.book("log").write(log.id, log) } - fun getAllLogs() : List { - val logs:MutableList = mutableListOf() + fun getAllLogs(): List { + val logs: MutableList = mutableListOf() Paper.book("log").allKeys.forEach { logId -> - Paper.book("log").read(logId)?.let { - logs.add(it) - } + Paper.book("log").read(logId)?.let { logs.add(it) } } return logs.sortedBy { it.timestamp } } @@ -317,7 +316,10 @@ class DbManager { } } if (logsRemoved > 0) { - AbsLogger.info("DbManager", "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours") + AbsLogger.info( + "DbManager", + "cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours" + ) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index b57cc9f6..b83ade16 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -1,16 +1,10 @@ package com.audiobookshelf.app.managers -import android.app.DownloadManager +import android.content.Context import android.net.Uri +import android.os.StatFs import android.util.Log import androidx.documentfile.provider.DocumentFile -import com.anggrayudi.storage.callback.FileCallback -import com.anggrayudi.storage.file.DocumentFileCompat -import com.anggrayudi.storage.file.MimeType -import com.anggrayudi.storage.file.getAbsolutePath -import com.anggrayudi.storage.file.moveFileTo -import com.anggrayudi.storage.media.FileDescription -import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.models.DownloadItem @@ -19,41 +13,44 @@ import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject import java.io.File -import java.io.FileOutputStream -import java.util.* +import java.io.FileInputStream +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.max +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import okhttp3.Call -/** Manages download items and their parts. */ +/** Manages the process-owned queue for app-managed downloads. */ class DownloadItemManager( - var downloadManager: DownloadManager, - private var folderScanner: FolderScanner, - var mainActivity: MainActivity, + private val folderScanner: FolderScanner, + private val context: Context, private var clientEventEmitter: DownloadEventEmitter ) { - val tag = "DownloadItemManager" - private val maxSimultaneousDownloads = 3 - private var jacksonMapper = + private val tag = "DownloadItemManager" + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val activeCalls = ConcurrentHashMap() + private val safFolderLocks = ConcurrentHashMap() + private val reservations = mutableMapOf() + private val lastPersistTime = mutableMapOf() + private var watcherRunning = false + private val jacksonMapper = jacksonObjectMapper() .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) - enum class DownloadCheckStatus { - InProgress, - Successful, - Failed - } - - var downloadItemQueue: MutableList = - mutableListOf() // All pending and downloading items - var currentDownloadItemParts: MutableList = - mutableListOf() // Item parts currently being downloaded + var downloadItemQueue: MutableList = mutableListOf() + private set + var currentDownloadItemParts: MutableList = mutableListOf() + private set interface DownloadEventEmitter { fun onDownloadItem(downloadItem: DownloadItem) fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart) fun onDownloadItemComplete(jsobj: JSObject) + fun onQueueChanged(hasWork: Boolean) } interface InternalProgressCallback { @@ -61,323 +58,455 @@ class DownloadItemManager( fun onComplete(failed: Boolean) } - companion object { - var isDownloading: Boolean = false + init { + IncompleteDownloadCleanup.cleanupExpired(context) } - /** Adds a download item to the queue and starts processing the queue. */ - fun addDownloadItem(downloadItem: DownloadItem) { - DeviceManager.dbManager.saveDownloadItem(downloadItem) - Log.i(tag, "Add download item ${downloadItem.media.metadata.title}") + @Synchronized + fun setEventEmitter(eventEmitter: DownloadEventEmitter) { + clientEventEmitter = eventEmitter + downloadItemQueue.forEach(clientEventEmitter::onDownloadItem) + notifyQueueChanged() + } + @Synchronized + fun restoreQueue() { + if (downloadItemQueue.isNotEmpty()) return + DeviceManager.dbManager.getDownloadItems().forEach { item -> + if (item.isDownloadFinished) { + downloadItemQueue.add(item) + checkDownloadItemFinished(item) + return@forEach + } + item.downloadItemParts.forEach { part -> + if (part.moved) return@forEach + if (item.terminalFailureAt != null && part.failed) return@forEach + part.downloadId = null + part.isMoving = false + part.failed = false + part.completed = false + part.waitingForSpace = false + part.bytesDownloaded = File(part.destinationPath).takeIf(File::exists)?.length() ?: 0L + } + downloadItemQueue.add(item) + if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item) + clientEventEmitter.onDownloadItem(item) + } + checkUpdateDownloadQueue() + notifyQueueChanged() + } + + @Synchronized + fun addDownloadItem(downloadItem: DownloadItem) { + val existingItem = downloadItemQueue.find { it.id == downloadItem.id } + if (existingItem != null) { + if (existingItem.terminalFailureAt != null) { + retryDownloadItem(existingItem) + checkUpdateDownloadQueue() + notifyQueueChanged() + } + return + } + persist(downloadItem, force = true) downloadItemQueue.add(downloadItem) clientEventEmitter.onDownloadItem(downloadItem) checkUpdateDownloadQueue() + notifyQueueChanged() } - /** Checks and updates the download queue. */ + private fun retryDownloadItem(item: DownloadItem) { + item.terminalFailureAt = null + IncompleteDownloadCleanup.cancel(context, item.id) + item.downloadItemParts.filter { it.failed }.forEach { part -> + part.failed = false + part.completed = false + part.isMoving = false + part.downloadId = null + part.retryCount = 0 + } + persist(item, force = true) + } + + @Synchronized + fun cancelAll() { + activeCalls.values.forEach(Call::cancel) + activeCalls.clear() + downloadItemQueue.forEach { item -> + item.downloadItemParts.forEach { part -> File(part.destinationPath).delete() } + DeviceManager.dbManager.removeDownloadItem(item.id) + } + currentDownloadItemParts.clear() + reservations.clear() + downloadItemQueue.clear() + notifyQueueChanged() + } + + @Synchronized + fun hasWork(): Boolean = + downloadItemQueue.any { item -> + item.downloadItemParts.any { part -> + (!part.completed && !part.failed) || part.isMoving + } + } + + @Synchronized private fun checkUpdateDownloadQueue() { - for (downloadItem in downloadItemQueue) { - val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size - val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet) - Log.d( - tag, - "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}" - ) - - if (nextDownloadItemParts.isNotEmpty()) { - processDownloadItemParts(nextDownloadItemParts) - } - - if (currentDownloadItemParts.size >= maxSimultaneousDownloads) { - break + downloadItemQueue.toList().forEach { item -> + val slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size + if (slots <= 0) return@forEach + item.getNextDownloadItemParts(slots).forEach { part -> + val existingFile = findSharedStorageFile(part) + if (existingFile != null) { + part.bytesDownloaded = existingFile.length() + part.progress = 100L + part.completedDestinationUri = existingFile.uri.toString() + File(part.destinationPath).delete() + completePart(item, part) + clientEventEmitter.onDownloadItemPartUpdate(part) + } else if (tryReserve(part)) startDownload(item, part) + else { + part.waitingForSpace = true + part.lastUpdateTime = System.currentTimeMillis() + persist(item) + clientEventEmitter.onDownloadItemPartUpdate(part) + } } } - - if (currentDownloadItemParts.isNotEmpty()) startWatchingDownloads() + if (hasWork()) startWatchingDownloads() else notifyQueueChanged() } - /** Processes the download item parts. */ - private fun processDownloadItemParts(nextDownloadItemParts: List) { - nextDownloadItemParts.forEach { - if (it.isInternalStorage) { - startInternalDownload(it) - } else { - startExternalDownload(it) - } - } + private fun startDownload(item: DownloadItem, part: DownloadItemPart) { + val stagingFile = File(part.destinationPath) + stagingFile.parentFile?.mkdirs() + part.downloadId = APP_MANAGED_DOWNLOAD_ID + part.waitingForSpace = false + part.lastUpdateTime = System.currentTimeMillis() + currentDownloadItemParts.add(part) + persist(item, force = true) + val activeConfig = DeviceManager.serverConnectionConfig + val token = + if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token + else + DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token + ?: DeviceManager.token + activeCalls[part.id] = + InternalDownloadManager( + stagingFile, + part.fileSize, + object : InternalProgressCallback { + override fun onProgress(totalBytesWritten: Long, progress: Long) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + part.bytesDownloaded = totalBytesWritten + part.progress = progress + part.lastUpdateTime = System.currentTimeMillis() + persist(item) + } + } + + override fun onComplete(failed: Boolean) { + synchronized(this@DownloadItemManager) { + if (part !in currentDownloadItemParts) return + part.failed = failed + part.completed = !failed + part.lastUpdateTime = System.currentTimeMillis() + activeCalls.remove(part.id) + persist(item, force = true) + } + } + }, + { hasAvailableSpace(part) } + ) + .download(serverUrl(item, part), token) } - /** Starts an internal download. */ - private fun startInternalDownload(downloadItemPart: DownloadItemPart) { - val file = File(downloadItemPart.finalDestinationPath) - file.parentFile?.mkdirs() - - val fileOutputStream = FileOutputStream(downloadItemPart.finalDestinationPath) - val internalProgressCallback = - object : InternalProgressCallback { - override fun onProgress(totalBytesWritten: Long, progress: Long) { - downloadItemPart.bytesDownloaded = totalBytesWritten - downloadItemPart.progress = progress - } - - override fun onComplete(failed: Boolean) { - downloadItemPart.failed = failed - downloadItemPart.completed = true - } - } - - Log.d( - tag, - "Start internal download to destination path ${downloadItemPart.finalDestinationPath} from ${downloadItemPart.serverUrl}" - ) - InternalDownloadManager(fileOutputStream, internalProgressCallback) - .download(downloadItemPart.serverUrl) - downloadItemPart.downloadId = 1 - currentDownloadItemParts.add(downloadItemPart) - } - - /** Starts an external download. */ - private fun startExternalDownload(downloadItemPart: DownloadItemPart) { - val dlRequest = downloadItemPart.getDownloadRequest() - val downloadId = downloadManager.enqueue(dlRequest) - downloadItemPart.downloadId = downloadId - Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId") - currentDownloadItemParts.add(downloadItemPart) - } - - /** Starts watching the downloads. */ + @Synchronized private fun startWatchingDownloads() { - if (isDownloading) return // Already watching - - GlobalScope.launch(Dispatchers.IO) { - Log.d(tag, "Starting watching downloads") - isDownloading = true - - while (currentDownloadItemParts.isNotEmpty()) { - val itemParts = currentDownloadItemParts.filter { !it.isMoving } - for (downloadItemPart in itemParts) { - if (downloadItemPart.isInternalStorage) { - handleInternalDownloadPart(downloadItemPart) - } else { - handleExternalDownloadPart(downloadItemPart) - } - } - - delay(500) - - if (currentDownloadItemParts.size < maxSimultaneousDownloads) { + if (watcherRunning) return + watcherRunning = true + scope.launch { + while (true) { + val activeParts = + synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() } + activeParts.forEach(::handlePartUpdate) + synchronized(this@DownloadItemManager) { checkUpdateDownloadQueue() - } - } - - Log.d(tag, "Finished watching downloads") - isDownloading = false - } - } - - /** Handles an internal download part. */ - private fun handleInternalDownloadPart(downloadItemPart: DownloadItemPart) { - clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) - - if (downloadItemPart.completed) { - val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId } - downloadItem?.let { checkDownloadItemFinished(it) } - currentDownloadItemParts.remove(downloadItemPart) - } - } - - /** Handles an external download part. */ - private fun handleExternalDownloadPart(downloadItemPart: DownloadItemPart) { - val downloadCheckStatus = checkDownloadItemPart(downloadItemPart) - clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart) - - // Will move to final destination, remove current item parts, and check if download item is - // finished - handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart) - } - - /** Checks the status of a download item part. */ - private fun checkDownloadItemPart(downloadItemPart: DownloadItemPart): DownloadCheckStatus { - val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed - - val query = DownloadManager.Query().setFilterById(downloadId) - downloadManager.query(query).use { - if (it.moveToFirst()) { - val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) - val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS) - val bytesDownloadedColumnIndex = - it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) - - val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0 - val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0 - val bytesDownloadedSoFar = - if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0 - Log.d( - tag, - "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus" - ) - - return when (downloadStatus) { - DownloadManager.STATUS_SUCCESSFUL -> { - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful") - downloadItemPart.completed = true - downloadItemPart.progress = 1 - downloadItemPart.bytesDownloaded = bytesDownloadedSoFar - - DownloadCheckStatus.Successful - } - DownloadManager.STATUS_FAILED -> { - Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed") - downloadItemPart.completed = true - downloadItemPart.failed = true - - DownloadCheckStatus.Failed - } - else -> { - val percentProgress = - if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0 - Log.d( - tag, - "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%" - ) - downloadItemPart.progress = percentProgress - downloadItemPart.bytesDownloaded = bytesDownloadedSoFar - - DownloadCheckStatus.InProgress + if (!hasWork()) { + watcherRunning = false + notifyQueueChanged() + return@launch } } - } else { - Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager") - downloadItemPart.completed = true - downloadItemPart.failed = true - return DownloadCheckStatus.Failed + delay(WATCH_INTERVAL_MS) } } } - /** Handles the result of a download item part check. */ - private fun handleDownloadItemPartCheck( - downloadCheckStatus: DownloadCheckStatus, - downloadItemPart: DownloadItemPart - ) { - val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId } - if (downloadItem == null) { - Log.e( - tag, - "Download item part finished but download item not found ${downloadItemPart.filename}" - ) - currentDownloadItemParts.remove(downloadItemPart) - } else if (downloadCheckStatus == DownloadCheckStatus.Successful) { - moveDownloadedFile(downloadItem, downloadItemPart) - } else if (downloadCheckStatus != DownloadCheckStatus.InProgress) { - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) + private fun handlePartUpdate(part: DownloadItemPart) { + clientEventEmitter.onDownloadItemPartUpdate(part) + val item = + synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } } + ?: run { + removeActivePart(part) + return + } + if (!part.completed && !part.failed) { + val lastUpdate = part.lastUpdateTime ?: return + if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { + Log.w(tag, "Download stalled: ${part.filename}") + activeCalls.remove(part.id)?.cancel() + failOrRetry(item, part, "Download stalled") + } + return + } + if (part.failed) { + failOrRetry(item, part, "Transfer failed") + return + } + if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part) + } + + @Synchronized + private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) { + removeActivePart(part) + part.retryCount += 1 + reservations.remove(part.destinationPath) + if (part.retryCount > MAX_RETRIES) { + Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") + part.failed = true + part.completed = false + part.downloadId = null + item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis() + persist(item, force = true) + IncompleteDownloadCleanup.schedule(context, item) + notifyQueueChanged() + return + } + part.failed = false + part.completed = false + part.downloadId = null + part.isMoving = false + persist(item, force = true) + } + + private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) { + if (part.moved || part.isMoving) return + part.isMoving = true + val stagingFile = File(part.destinationPath) + val finalFile = File(part.finalDestinationPath) + finalFile.parentFile?.mkdirs() + val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup") + try { + if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup") + if (finalFile.exists() && !finalFile.renameTo(backup)) + throw IllegalStateException("Could not protect existing file") + if (!stagingFile.renameTo(finalFile)) { + if (backup.exists()) backup.renameTo(finalFile) + throw IllegalStateException("Could not finalize internal staging file") + } + backup.delete() + completePart(item, part) + } catch (e: Exception) { + part.isMoving = false + part.failed = true + failOrRetry(item, part, e.message ?: "Internal finalization failed") } } - /** Moves the downloaded file to its final destination. */ - private fun moveDownloadedFile(downloadItem: DownloadItem, downloadItemPart: DownloadItemPart) { - val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri) - Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}") + private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) { + if (part.moved || part.isMoving) return + val root = + DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) + ?: return failFinalization(item, part, "Could not resolve SAF destination") + part.isMoving = true + persist(item, force = true) + scope.launch { + try { + if (!hasAvailableSpace(part)) + throw IllegalStateException("Insufficient storage for SAF copy") + val folderKey = "${root.uri}/${part.finalDestinationSubfolder}" + val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() } + val folder = + synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) } + ?: throw IllegalStateException("Could not create SAF destination folder") + val temporaryName = ".${part.filename}.${part.id.hashCode()}.part" + folder.findFile(temporaryName)?.delete() + val temporary = + folder.createFile(mimeTypeFor(part), temporaryName) + ?: throw IllegalStateException("Could not create SAF temporary file") + val staging = File(part.destinationPath) + FileInputStream(staging).use { input -> + context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) } + ?: throw IllegalStateException("Could not open SAF output stream") + } + if (temporary.length() != staging.length()) + throw IllegalStateException("SAF copy size mismatch") + val existing = folder.findFile(part.filename) + if (existing != null && !existing.delete()) + throw IllegalStateException("Could not replace existing file") + if (!temporary.renameTo(part.filename)) + throw IllegalStateException("Could not finalize SAF temporary file") + val destination = + folder.findFile(part.filename) + ?: throw IllegalStateException("Could not reopen finalized SAF file") + if (destination.length() != staging.length()) + throw IllegalStateException("SAF final size mismatch") + if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}") + part.completedDestinationUri = destination.uri.toString() + completePart(item, part) + } catch (e: Exception) { + failFinalization(item, part, "SAF copy failed: ${e.message}") + } + } + } - val fcb = - object : FileCallback() { - override fun onPrepare() { - Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE") - } + @Synchronized + private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { + Log.e(tag, message) + part.isMoving = false + part.failed = true + failOrRetry(item, part, message) + } - override fun onFailed(errorCode: ErrorCode) { - Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode") - downloadItemPart.failed = true - downloadItemPart.isMoving = false - file?.delete() - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) - } + @Synchronized + private fun completePart(item: DownloadItem, part: DownloadItemPart) { + part.moved = true + part.completed = true + part.failed = false + part.isMoving = false + reservations.remove(part.destinationPath) + removeActivePart(part) + persist(item, force = true) + checkDownloadItemFinished(item) + } - override fun onCompleted(result: Any) { - Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED") - val resultDocFile = result as DocumentFile - Log.d( - tag, - "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}" - ) - - // Rename to fix appended .mp3 on m4b/m4a files - // REF: https://github.com/anggrayudi/SimpleStorage/issues/94 - val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: "" - if (docNameLowerCase.endsWith(".m4b.mp3") || docNameLowerCase.endsWith(".m4a.mp3") - ) { - resultDocFile.renameTo(downloadItemPart.filename) + private fun checkDownloadItemFinished(item: DownloadItem) { + if (!item.isDownloadFinished) return + scope.launch { + folderScanner.scanDownloadItem(item) { scanResult -> + val event = + JSObject().apply { + put("libraryItemId", item.id) + put("localFolderId", item.localFolder.id) + scanResult?.localLibraryItem?.let { + put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it))) + } + scanResult?.localMediaProgress?.let { + put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it))) + } } - - downloadItemPart.moved = true - downloadItemPart.isMoving = false - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) - } - } - - val localFolderFile = - DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl)) - if (localFolderFile == null) { - // Failed - downloadItemPart.failed = true - Log.e(tag, "Local Folder File from uri is null") - checkDownloadItemFinished(downloadItem) - currentDownloadItemParts.remove(downloadItemPart) - } else { - downloadItemPart.isMoving = true - val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE - val fileDescription = - FileDescription( - downloadItemPart.filename, - downloadItemPart.finalDestinationSubfolder, - mimetype - ) - file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb) + clientEventEmitter.onDownloadItemComplete(event) + synchronized(this@DownloadItemManager) { + downloadItemQueue.remove(item) + DeviceManager.dbManager.removeDownloadItem(item.id) + notifyQueueChanged() + } + } } } - /** Checks if a download item is finished and processes it. */ - private fun checkDownloadItemFinished(downloadItem: DownloadItem) { - if (downloadItem.isDownloadFinished) { - Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}") + private fun tryReserve(part: DownloadItemPart): Boolean { + if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) return false + val staging = File(part.destinationPath) + staging.parentFile?.mkdirs() + val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES + val remaining = + (expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L) + val required = if (part.isInternalStorage) remaining else remaining + expectedSize + val key = storageKey(staging) + val fs = statFsFor(staging) + val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L) + val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum() + if (fs.availableBytes - alreadyReserved < required + headroom) return false + reservations[part.destinationPath] = required + return true + } - GlobalScope.launch(Dispatchers.IO) { - folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult -> - Log.d( - tag, - "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}" - ) + private fun hasAvailableSpace(part: DownloadItemPart): Boolean { + val staging = File(part.destinationPath) + val fs = statFsFor(staging) + return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L) + } - val jsobj = - JSObject().apply { - put("libraryItemId", downloadItem.id) - put("localFolderId", downloadItem.localFolder.id) + private fun statFsFor(staging: File): StatFs { + var directory = staging.parentFile ?: context.filesDir + directory.mkdirs() + while (!directory.exists()) directory = directory.parentFile ?: context.filesDir + return StatFs(directory.absolutePath) + } - downloadItemScanResult?.localLibraryItem?.let { localLibraryItem -> - put( - "localLibraryItem", - JSObject(jacksonMapper.writeValueAsString(localLibraryItem)) - ) - } - downloadItemScanResult?.localMediaProgress?.let { localMediaProgress -> - put( - "localMediaProgress", - JSObject(jacksonMapper.writeValueAsString(localMediaProgress)) - ) - } + private fun storageKey(file: File): String = + if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal" + else "external" + + @Synchronized + private fun removeActivePart(part: DownloadItemPart) { + activeCalls.remove(part.id) + currentDownloadItemParts.remove(part) + } + + private fun persist(item: DownloadItem, force: Boolean = false) { + val now = System.currentTimeMillis() + if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return + lastPersistTime[item.id] = now + DeviceManager.dbManager.saveDownloadItem(item) + } + + private fun notifyQueueChanged() { + clientEventEmitter.onQueueChanged(hasWork()) + } + + fun destroy() { + activeCalls.values.forEach(Call::cancel) + activeCalls.clear() + scope.cancel() + } + + private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? { + var current = root + relativePath.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null + } + return current + } + + private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? { + if (part.isInternalStorage) return null + val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null + var folder = root + part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment -> + if (segment == "." || segment == "..") return null + folder = folder.findFile(segment) ?: return null + } + val file = folder.findFile(part.filename) ?: return null + if (!file.isFile) return null + if (part.fileSize > 0L && file.length() != part.fileSize) return null + if (part.fileSize <= 0L && file.length() <= 0L) return null + return file + } + + private fun mimeTypeFor(part: DownloadItemPart): String = + part.audioTrack?.mimeType + ?: when (part.ebookFile?.ebookFormat?.lowercase()) { + "epub" -> "application/epub+zip" + "pdf" -> "application/pdf" + else -> "image/jpeg" } - launch(Dispatchers.Main) { - clientEventEmitter.onDownloadItemComplete(jsobj) - downloadItemQueue.remove(downloadItem) - DeviceManager.dbManager.removeDownloadItem(downloadItem.id) - } - } - } - } + private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String { + val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else "" + return "${item.serverAddress}${part.serverPath}$rawCover" + } + + private companion object { + const val APP_MANAGED_DOWNLOAD_ID = -1L + const val MAX_SIMULTANEOUS_DOWNLOADS = 3 + const val WATCH_INTERVAL_MS = 1_000L + const val STALL_TIMEOUT_MS = 60_000L + const val MAX_RETRIES = 5 + const val PERSIST_INTERVAL_MS = 2_000L + const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L + const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt new file mode 100644 index 00000000..4fd89085 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -0,0 +1,94 @@ +package com.audiobookshelf.app.managers + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.documentfile.provider.DocumentFile +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.models.DownloadItem +import java.io.File +import java.util.concurrent.TimeUnit + +/** 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 + private const val WORK_PREFIX = "incomplete-download-" + + fun schedule(context: Context, item: DownloadItem) { + val failedAt = item.terminalFailureAt ?: return + val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L) + val request = OneTimeWorkRequestBuilder() + .setInitialDelay(delay, TimeUnit.MILLISECONDS) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request) + } + + fun cancel(context: Context, itemId: String) { + WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId) + } + + /** Removes failures retained longer than 24 hours when scheduled work did not run. */ + fun cleanupExpired(context: Context) { + val now = System.currentTimeMillis() + DeviceManager.dbManager.getDownloadItems() + .filter { item -> isEligible(item, now) } + .forEach { item -> + deleteItem(context, item) + } + } + + private fun isEligible(item: DownloadItem, now: Long): Boolean { + val failedAt = item.terminalFailureAt ?: return false + if (now - failedAt < RETENTION_MS) return false + return item.downloadItemParts.all { part -> + part.moved || (part.failed && !part.isMoving) + } + } + + private fun deleteItem(context: Context, item: DownloadItem) { + item.downloadItemParts.forEach { part -> + deleteAppOwnedFile(context, File(part.destinationPath)) + if (part.isInternalStorage && part.moved) { + deleteAppOwnedFile(context, File(part.finalDestinationPath)) + } else if (!part.isInternalStorage && part.moved) { + part.completedDestinationUri?.let { uriString -> + try { + DocumentFile.fromSingleUri(context, Uri.parse(uriString))?.delete() + } catch (e: Exception) { + Log.w(tag, "Could not delete expired SAF document for ${part.filename}", e) + } + } + } + } + DeviceManager.dbManager.removeDownloadItem(item.id) + cancel(context, item.id) + Log.i(tag, "Deleted terminally failed download item ${item.id}") + } + + private fun deleteAppOwnedFile(context: Context, file: File) { + val path = file.absolutePath + val internal = context.filesDir.absolutePath + val external = context.getExternalFilesDir(null)?.absolutePath + if (path.startsWith(internal) || (external != null && path.startsWith(external))) { + if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path") + file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete() + } else { + Log.w(tag, "Refusing to delete non-app-owned path $path") + } + } +} + +class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) { + override fun doWork(): Result { + DbManager.initialize(applicationContext) + IncompleteDownloadCleanup.cleanupExpired(applicationContext) + return Result.success() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index 3f1f9348..59dc6026 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -1,114 +1,135 @@ package com.audiobookshelf.app.managers import android.util.Log -import java.io.* +import java.io.File +import java.io.FileOutputStream +import java.io.IOException import java.util.concurrent.TimeUnit -import okhttp3.* +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response -/** - * Manages the internal download process. - * - * @property outputStream The output stream to write the downloaded data. - * @property progressCallback The callback to report download progress. - */ +/** Streams a download into an app-owned staging file. */ class InternalDownloadManager( - private val outputStream: FileOutputStream, - private val progressCallback: DownloadItemManager.InternalProgressCallback -) : AutoCloseable { - + private val destinationFile: File, + private val expectedSize: Long, + private val progressCallback: DownloadItemManager.InternalProgressCallback, + private val hasAvailableSpace: () -> Boolean +) { private val tag = "InternalDownloadManager" - private val client: OkHttpClient = - OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).build() - private val writer = BinaryFileWriter(outputStream, progressCallback) - /** - * Downloads a file from the given URL. + * Starts or resumes a download. * - * @param url The URL to download the file from. - * @throws IOException If an I/O error occurs. + * @param url download URL + * @param token access token sent in the Authorization header + * @return active call, used to cancel a stalled transfer */ - @Throws(IOException::class) - fun download(url: String) { - val request: Request = Request.Builder().url(url).addHeader("Accept-Encoding", "identity").build() - client.newCall(request) - .enqueue( - object : Callback { - override fun onFailure(call: Call, e: IOException) { - Log.e(tag, "Download URL $url FAILED", e) - progressCallback.onComplete(true) - } + fun download(url: String, token: String): Call { + destinationFile.parentFile?.mkdirs() + val existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L + val request = + Request.Builder() + .url(url) + .addHeader("Accept-Encoding", "identity") + .addHeader("Authorization", "Bearer $token") + .apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") } + .build() + val call = client.newCall(request) + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(tag, "Download URL failed", e) + progressCallback.onComplete(true) + } - override fun onResponse(call: Call, response: Response) { - response.body?.let { responseBody -> - val length: Long = response.header("Content-Length")?.toLongOrNull() ?: 0L - writer.write(responseBody.byteStream(), length) + override fun onResponse(call: Call, response: Response) { + response.use { + try { + if (response.code == 416 && expectedSize > 0L && existingBytes == expectedSize + ) { + progressCallback.onProgress(existingBytes, 100L) + progressCallback.onComplete(false) + return + } + val append = + existingBytes > 0L && + response.code == 206 && + hasExpectedRange(response, existingBytes) + if (existingBytes > 0L && !append && response.code != 200) { + Log.e( + tag, + "Invalid resume response ${response.code} for offset $existingBytes" + ) + progressCallback.onComplete(true) + return + } + if (!response.isSuccessful || response.body == null) { + Log.e(tag, "Download HTTP failure ${response.code}") + progressCallback.onComplete(true) + return + } + + val startingBytes = if (append) existingBytes else 0L + val responseLength = response.body!!.contentLength() + val totalLength = + if (expectedSize > 0L) expectedSize + else if (responseLength >= 0L) startingBytes + responseLength else 0L + + FileOutputStream(destinationFile, append).use { output -> + response.body!!.byteStream().use { input -> + val buffer = ByteArray(CHUNK_SIZE) + var totalBytes = startingBytes + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (!hasAvailableSpace()) + throw IOException("Download paused to preserve free storage") + output.write(buffer, 0, read) + totalBytes += read + val progress = + if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L + progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L)) } - ?: run { - Log.e(tag, "Response doesn't contain a file") - progressCallback.onComplete(true) - } } } - ) + + if (expectedSize > 0L && destinationFile.length() != expectedSize) { + Log.e( + tag, + "Downloaded size ${destinationFile.length()} did not match $expectedSize" + ) + progressCallback.onComplete(true) + } else { + progressCallback.onComplete(false) + } + } catch (e: IOException) { + Log.e(tag, "Could not write staging file", e) + progressCallback.onComplete(true) + } + } + } + } + ) + return call } - /** - * Closes the download manager and releases resources. - * - * @throws Exception If an error occurs during closing. - */ - @Throws(Exception::class) - override fun close() { - writer.close() - } -} - -/** - * Writes binary data to an output stream. - * - * @property outputStream The output stream to write the data to. - * @property progressCallback The callback to report write progress. - */ -class BinaryFileWriter( - private val outputStream: OutputStream, - private val progressCallback: DownloadItemManager.InternalProgressCallback -) : AutoCloseable { - - /** - * Writes data from the input stream to the output stream. - * - * @param inputStream The input stream to read the data from. - * @param length The total length of the data to be written. - * @return The total number of bytes written. - * @throws IOException If an I/O error occurs. - */ - @Throws(IOException::class) - fun write(inputStream: InputStream, length: Long): Long { - BufferedInputStream(inputStream).use { input -> - val dataBuffer = ByteArray(CHUNK_SIZE) - var totalBytes: Long = 0 - var readBytes: Int - while (input.read(dataBuffer).also { readBytes = it } != -1) { - totalBytes += readBytes - outputStream.write(dataBuffer, 0, readBytes) - progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length) - } - progressCallback.onComplete(false) - return totalBytes - } - } - - /** - * Closes the writer and releases resources. - * - * @throws IOException If an error occurs during closing. - */ - @Throws(IOException::class) - override fun close() { - outputStream.close() - } - - companion object { - private const val CHUNK_SIZE = 8192 // Increased chunk size for better performance + private fun hasExpectedRange(response: Response, offset: Long): Boolean { + val range = response.header("Content-Range") ?: return false + val match = CONTENT_RANGE.matchEntire(range) ?: return false + return match.groupValues[1].toLongOrNull() == offset && + match.groupValues[2].toLongOrNull()?.let { it >= offset } == true + } + + private companion object { + const val CHUNK_SIZE = 512 * 1024 // 512 KB + val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(?:\\d+|\\*)") + val client = + OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt index 918744eb..af1657b7 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -6,34 +6,37 @@ import com.audiobookshelf.app.data.MediaType import com.fasterxml.jackson.annotation.JsonIgnore data class DownloadItem( - val id: String, - val libraryItemId:String, - val episodeId:String?, - val userMediaProgress: MediaProgress?, - val serverConnectionConfigId:String, - val serverAddress:String, - val serverUserId:String, - val mediaType: String, - val itemFolderPath:String, - val localFolder: LocalFolder, - val itemTitle: String, - val itemSubfolder: String, - val media: MediaType, - val downloadItemParts: MutableList + val id: String, + val libraryItemId: String, + val episodeId: String?, + val userMediaProgress: MediaProgress?, + val serverConnectionConfigId: String, + val serverAddress: String, + val serverUserId: String, + val mediaType: String, + val itemFolderPath: String, + val localFolder: LocalFolder, + val itemTitle: String, + val itemSubfolder: String, + val media: MediaType, + val downloadItemParts: MutableList, + @JsonIgnore var terminalFailureAt: Long? = null ) { @get:JsonIgnore - val isInternalStorage get() = localFolder.id.startsWith("internal-") + val isInternalStorage + get() = localFolder.id.startsWith("internal-") @get:JsonIgnore - val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving } + val isDownloadFinished + get() = !downloadItemParts.any { !it.completed || it.isMoving || it.failed } @JsonIgnore - fun getNextDownloadItemParts(limit:Int): MutableList { + fun getNextDownloadItemParts(limit: Int): MutableList { val itemParts = mutableListOf() if (limit == 0) return itemParts for (it in downloadItemParts) { - if (!it.completed && it.downloadId == null) { + if (!it.completed && !it.failed && it.downloadId == null) { itemParts.add(it) if (itemParts.size >= limit) break } diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt index 814b8544..7dadbca8 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -1,6 +1,5 @@ package com.audiobookshelf.app.models -import android.app.DownloadManager import android.net.Uri import android.util.Log import com.audiobookshelf.app.data.AudioTrack @@ -16,6 +15,7 @@ data class DownloadItemPart( val downloadItemId: String, val filename: String, val fileSize: Long, + @JsonIgnore val destinationPath: String, val finalDestinationPath:String, val serverPath: String, val localFolderName: String, @@ -31,28 +31,29 @@ data class DownloadItemPart( @JsonIgnore val uri: Uri, @JsonIgnore val destinationUri: Uri, @JsonIgnore val finalDestinationUri: Uri, + @JsonIgnore var completedDestinationUri: String?, val finalDestinationSubfolder: String, var downloadId: Long?, + @JsonIgnore var lastUpdateTime: Long?, var progress: Long, - var bytesDownloaded: Long + var bytesDownloaded: Long, + @JsonIgnore var retryCount: Int = 0, + @JsonIgnore var waitingForSpace: Boolean = false ) { companion object { fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart { val destinationUri = Uri.fromFile(destinationFile) val finalDestinationUri = Uri.fromFile(finalDestinationFile) + val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else "" + val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover") - var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}" - if (serverPath.endsWith("/cover")) { - downloadUrl += "&raw=1" // Download raw cover image - } - - val downloadUri = Uri.parse(downloadUrl) - Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri") + Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath") return DownloadItemPart( id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath), downloadItemId, filename = filename, fileSize = fileSize, + destinationPath = destinationFile.absolutePath, finalDestinationPath = finalDestinationFile.absolutePath, serverPath = serverPath, localFolderName = localFolder.name, @@ -68,8 +69,10 @@ data class DownloadItemPart( uri = downloadUri, destinationUri = destinationUri, finalDestinationUri = finalDestinationUri, + completedDestinationUri = null, finalDestinationSubfolder = subfolder, downloadId = null, + lastUpdateTime = null, progress = 0, bytesDownloaded = 0 ) @@ -79,16 +82,4 @@ data class DownloadItemPart( @get:JsonIgnore val isInternalStorage get() = localFolderId.startsWith("internal-") - @get:JsonIgnore - val serverUrl get() = uri.toString() - - @JsonIgnore - fun getDownloadRequest(): DownloadManager.Request { - val dlRequest = DownloadManager.Request(uri) - dlRequest.setTitle(filename) - dlRequest.setDescription("Downloading to $localFolderName with filename $filename") - dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) - dlRequest.setDestinationUri(destinationUri) - return dlRequest - } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt index 41d7cbf7..b1d80951 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -229,7 +229,7 @@ class AbsAudioPlayer : Plugin() { return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) } } - if (!it.hasTracks(episode)) { + if (!it.hasTracks(mainActivity, episode)) { return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}")) } @@ -325,10 +325,10 @@ class AbsAudioPlayer : Plugin() { @PluginMethod fun seek(call: PluginCall) { - val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds + val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional Log.d(tag, "seek action to $time") Handler(Looper.getMainLooper()).post { - playerNotificationService.seekPlayer(time * 1000L) // convert to ms + playerNotificationService.seekPlayer((time * 1000L).toLong()) call.resolve() } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt index bb96e554..0964d470 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -40,7 +40,7 @@ class AbsDatabase : Plugin() { secureStorage = SecureStorage(mainActivity) DeviceManager.dbManager.cleanLocalMediaProgress() - DeviceManager.dbManager.cleanLocalLibraryItems() + DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity) DeviceManager.dbManager.cleanLogs() } @@ -119,7 +119,7 @@ class AbsDatabase : Plugin() { @PluginMethod fun setCurrentServerConnectionConfig(call:PluginCall) { - Log.d(tag, "setCurrentServerConnectionConfig ${call.data}") + Log.d(tag, "setCurrentServerConnectionConfig called") val serverConfigPayload = jacksonMapper.readValue(call.data.toString()) var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id } @@ -558,7 +558,7 @@ class AbsDatabase : Plugin() { @PluginMethod fun updateDeviceSettings(call:PluginCall) { // Returns device data - Log.d(tag, "updateDeviceSettings ${call.data}") + Log.d(tag, "updateDeviceSettings called") val newDeviceSettings = jacksonMapper.readValue(call.data.toString()) Handler(Looper.getMainLooper()).post { @@ -576,7 +576,7 @@ class AbsDatabase : Plugin() { @PluginMethod fun getMediaItemHistory(call:PluginCall) { // Returns device data - Log.d(tag, "getMediaItemHistory ${call.data}") + Log.d(tag, "getMediaItemHistory called") val mediaId = call.getString("mediaId") ?: "" GlobalScope.launch(Dispatchers.IO) { diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index d3e42029..d9dde269 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -1,17 +1,15 @@ package com.audiobookshelf.app.plugins -import android.app.DownloadManager -import android.content.Context import android.os.Environment 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 import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.services.DownloadServiceHost import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject @@ -27,9 +25,7 @@ class AbsDownloader : Plugin() { private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity - lateinit var downloadManager: DownloadManager lateinit var apiHandler: ApiHandler - lateinit var folderScanner: FolderScanner lateinit var downloadItemManager: DownloadItemManager private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { @@ -42,14 +38,44 @@ class AbsDownloader : Plugin() { override fun onDownloadItemComplete(jsobj:JSObject) { notifyListeners("onItemDownloadComplete", jsobj) } + override fun onQueueChanged(hasWork: Boolean) { + notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork)) + } }) override fun load() { mainActivity = (activity as MainActivity) - downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - folderScanner = FolderScanner(mainActivity) apiHandler = ApiHandler(mainActivity) - downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter) + downloadItemManager = DownloadServiceHost.ensure(mainActivity) + DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter) + } + + override fun handleOnDestroy() { + DownloadServiceHost.detachBridge() + super.handleOnDestroy() + } + + @PluginMethod + fun setDownloadNotificationStrings(call: PluginCall) { + DownloadServiceHost.setNotificationStrings( + mainActivity, + call.getString("preparing") ?: "Preparing downloads", + call.getString("downloadingFile") ?: "Downloading {0}", + call.getString("waitingForStorage") ?: "Waiting for available storage", + call.getString("downloads") ?: "Downloads", + call.getString("cancel") ?: "Cancel") + call.resolve() + } + + /** Replays restored queue items when the frontend subscribes to download events. */ + @PluginMethod(returnType = PluginMethod.RETURN_NONE) + override fun addListener(call: PluginCall) { + super.addListener(call) + if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) { + downloadItemManager.downloadItemQueue.forEach { item -> + notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item))) + } + } } @PluginMethod @@ -79,7 +105,7 @@ class AbsDownloader : Plugin() { if (localFolder == null && localFolderId.startsWith("internal-")) { Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId") - localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType) + localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) } @@ -132,7 +158,13 @@ class AbsDownloader : Plugin() { private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) { val isInternal = localFolder.id.startsWith("internal-") - val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) + val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" + val tempFolderPath = + if (isInternal) { + "${mainActivity.filesDir}/download-staging/${libraryItem.id}" + } else { + "${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}" + } Log.d(tag, "downloadCacheDirectory=$tempFolderPath") @@ -143,7 +175,7 @@ class AbsDownloader : Plugin() { val tracks = libraryItem.media.getAudioTracks() Log.d(tag, "Starting library item download with ${tracks.size} tracks") val itemSubfolder = "$bookAuthor/$bookTitle" - val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder" + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder" val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) val book = libraryItem.media as Book @@ -152,17 +184,7 @@ class AbsDownloader : Plugin() { val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download" val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - val destinationFile = File("$tempFolderPath/$destinationFilename") - - if (destinationFile.exists()) { - Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}") - destinationFile.delete() - } - - if (finalDestinationFile.exists()) { - Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } + val destinationFile = File("$tempFolderPath/$destinationFilename.part") val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null) downloadItem.downloadItemParts.add(downloadItemPart) @@ -181,17 +203,7 @@ class AbsDownloader : Plugin() { Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - val destinationFile = File("$tempFolderPath/$destinationFilename") - - if (destinationFile.exists()) { - Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}") - destinationFile.delete() - } - - if (finalDestinationFile.exists()) { - Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } + val destinationFile = File("$tempFolderPath/$destinationFilename.part") val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null) downloadItem.downloadItemParts.add(downloadItemPart) @@ -205,24 +217,14 @@ class AbsDownloader : Plugin() { val serverPath = "/api/items/${libraryItem.id}/cover" val destinationFilename = "cover-${libraryItem.id}.jpg" - val destinationFile = File("$tempFolderPath/$destinationFilename") + val destinationFile = File("$tempFolderPath/$destinationFilename.part") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (destinationFile.exists()) { - Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}") - destinationFile.delete() - } - - if (finalDestinationFile.exists()) { - Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } - val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null) downloadItem.downloadItemParts.add(downloadItemPart) } - downloadItemManager.addDownloadItem(downloadItem) + DownloadServiceHost.enqueue(mainActivity, downloadItem) } } else { // Podcast episode download @@ -233,7 +235,7 @@ class AbsDownloader : Plugin() { val fileSize = audioTrack?.metadata?.size ?: 0 Log.d(tag, "Starting podcast episode download") - val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$podcastTitle" + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle" val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) @@ -241,13 +243,8 @@ class AbsDownloader : Plugin() { var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") - var destinationFile = File("$tempFolderPath/$destinationFilename") + var destinationFile = File("$tempFolderPath/$destinationFilename.part") var finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (finalDestinationFile.exists()) { - Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}") - finalDestinationFile.delete() - } - var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode) downloadItem.downloadItemParts.add(downloadItemPart) @@ -258,18 +255,14 @@ class AbsDownloader : Plugin() { serverPath = "/api/items/${libraryItem.id}/cover" destinationFilename = "cover.jpg" - destinationFile = File("$tempFolderPath/$destinationFilename") + destinationFile = File("$tempFolderPath/$destinationFilename.part") finalDestinationFile = File("$itemFolderPath/$destinationFilename") - if (finalDestinationFile.exists()) { - Log.d(tag, "Podcast cover already exists - not downloading cover again") - } else { - downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null) - downloadItem.downloadItemParts.add(downloadItemPart) - } + downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) } - downloadItemManager.addDownloadItem(downloadItem) + DownloadServiceHost.enqueue(mainActivity, downloadItem) } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt index 35828180..307c6c09 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -1,6 +1,7 @@ package com.audiobookshelf.app.plugins import android.app.AlertDialog +import android.content.Context import android.net.Uri import android.os.Build import android.util.Log @@ -23,40 +24,62 @@ import java.io.File class AbsFileSystem : Plugin() { private val TAG = "AbsFileSystem" private val tag = "AbsFileSystem" - private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var jacksonMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) lateinit var mainActivity: MainActivity override fun load() { mainActivity = (activity as MainActivity) - mainActivity.storage.storageAccessCallback = object : StorageAccessCallback { - override fun onRootPathNotSelected( - requestCode: Int, - rootPath: String, - uri: Uri, - selectedStorageType: StorageType, - expectedStorageType: StorageType - ) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + mainActivity.storage.storageAccessCallback = + object : StorageAccessCallback { + override fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onCanceledByUser(requestCode: Int) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + override fun onCanceledByUser(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + override fun onExpectedStorageNotSelected( + requestCode: Int, + selectedFolder: DocumentFile, + selectedStorageType: StorageType, + expectedBasePath: String, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onStoragePermissionDenied(requestCode: Int) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } - override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { - Log.d(TAG, "STORAGE ACCESS CALLBACK") - } - } + override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + } + } + + @PluginMethod + fun setFolderPickerStrings(call: PluginCall) { + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired")) + .putString(KEY_ALLOW, call.getString("allow")) + .putString(KEY_CANCEL, call.getString("cancel")) + .putString(KEY_ACCESS_DENIED, call.getString("accessDenied")) + .putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied")) + .apply() + call.resolve() } @PluginMethod @@ -65,60 +88,74 @@ class AbsFileSystem : Plugin() { val REQUEST_CODE_SELECT_FOLDER = 6 val REQUEST_CODE_SDCARD_ACCESS = 7 - mainActivity.storage.folderPickerCallback = object : FolderPickerCallback { - override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { - Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") - val absolutePath = folder.getAbsolutePath(activity) - val storageType = folder.getStorageType(activity) - val simplePath = folder.getSimplePath(activity) - val basePath = folder.getBasePath(activity) - val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT) + mainActivity.storage.folderPickerCallback = + object : FolderPickerCallback { + override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { + Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") + val absolutePath = folder.getAbsolutePath(activity) + val storageType = folder.getStorageType(activity) + val basePath = folder.getBasePath(activity) + val folderId = + android.util.Base64.encodeToString( + folder.id.toByteArray(), + android.util.Base64.DEFAULT + ) - val localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType) + val localFolder = + LocalFolder( + folderId, + folder.name ?: "", + folder.uri.toString(), + basePath, + absolutePath, + storageType.toString(), + mediaType + ) - DeviceManager.dbManager.saveLocalFolder(localFolder) - call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) - } + DeviceManager.dbManager.saveLocalFolder(localFolder) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) + } - override fun onStorageAccessDenied( - requestCode: Int, - folder: DocumentFile?, - storageType: StorageType, - storageId: String - ) { - Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") + override fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) { + Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") - val jsobj = JSObject() - if (requestCode == REQUEST_CODE_SELECT_FOLDER) { + val jsobj = JSObject() + if (requestCode == REQUEST_CODE_SELECT_FOLDER) { - val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) - builder.setMessage( - "You have no write access to this storage, thus selecting this folder is useless." + - "\nWould you like to grant access to this folder?") - builder.setNegativeButton("Dont Allow") { _, _ -> - run { - jsobj.put("error", "User Canceled, Access Denied") - call.resolve(jsobj) + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED)) + builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ -> + run { + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) + call.resolve(jsobj) + } + } + builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ -> + mainActivity.storageHelper.requestStorageAccess( + REQUEST_CODE_SDCARD_ACCESS, + initialPath = FileFullPath(mainActivity, storageId, "") + ) + } + builder.show() + } else { + Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) + call.resolve(jsobj) + } + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") + val jsobj = JSObject() + jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED)) + call.resolve(jsobj) + } } - } - builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) } - builder.show() - } else { - Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") - jsobj.put("error", "Access Denied") - call.resolve(jsobj) - } - } - - - override fun onStoragePermissionDenied(requestCode: Int) { - Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") - val jsobj = JSObject() - jsobj.put("error", "Permission Denied") - call.resolve(jsobj) - } - - } mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER) } @@ -152,7 +189,7 @@ class AbsFileSystem : Plugin() { val folderUrl = call.data.getString("folderUrl", "").toString() Log.d(TAG, "Check Folder Permissions for $folderUrl") - val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true) + val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true) val jsobj = JSObject() jsobj.put("value", hasAccess) @@ -196,26 +233,29 @@ class AbsFileSystem : Plugin() { if (localLibraryItem?.folderId?.startsWith("internal-") == true) { Log.d(tag, "Deleting internal library item at absolutePath $absolutePath") val file = File(absolutePath) - success = if (file.exists()) { - file.deleteRecursively() - } else { - true - } + success = + if (file.exists()) { + file.deleteRecursively() + } else { + true + } } else { var subfolderPathToDelete = "" localLibraryItem?.folderId?.let { folderId -> val folder = DeviceManager.dbManager.getLocalFolder(folderId) folder?.absolutePath?.let { folderPath -> val splitAbsolutePath = absolutePath.split("/") - val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") + val fullSubDir = + splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") if (fullSubDir != folderPath) { - val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> - if (_localLibraryItem.id == localLibraryItemId) { - false - } else { - _localLibraryItem.absolutePath.startsWith(fullSubDir) - } - } + val subdirHasAnItem = + DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> + if (_localLibraryItem.id == localLibraryItemId) { + false + } else { + _localLibraryItem.absolutePath.startsWith(fullSubDir) + } + } subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir } } @@ -266,4 +306,23 @@ class AbsFileSystem : Plugin() { call.resolve(JSObject("{\"success\":false}")) } } + + private fun folderPickerString(key: String, defaultValue: String): String = + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .getString(key, defaultValue) ?: defaultValue + + private companion object { + const val FOLDER_PICKER_PREFERENCES = "folder_picker" + const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required" + const val KEY_ALLOW = "allow" + const val KEY_CANCEL = "cancel" + const val KEY_ACCESS_DENIED = "access_denied" + const val KEY_PERMISSION_DENIED = "permission_denied" + const val DEFAULT_WRITE_ACCESS_REQUIRED = + "You do not have write access to this folder. Would you like to grant access?" + const val DEFAULT_ALLOW = "Allow" + const val DEFAULT_CANCEL = "Cancel" + const val DEFAULT_ACCESS_DENIED = "Access denied" + const val DEFAULT_PERMISSION_DENIED = "Permission denied" + } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt new file mode 100644 index 00000000..51e9ae31 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -0,0 +1,102 @@ +package com.audiobookshelf.app.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.models.DownloadItemPart + +/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */ +class DownloadService : Service() { + override fun onCreate() { + super.onCreate() + createChannel() + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.attachService(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) + else -> { + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.ensure(this) + } + } + return START_STICKY + } + + override fun onDestroy() { + DownloadServiceHost.detachService(this) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + fun onPartUpdate(part: DownloadItemPart) { + val strings = DownloadServiceHost.notificationStrings(this) + val text = + if (part.waitingForSpace) strings.waitingForStorage + else strings.downloadingFile.replace("{0}", part.filename) + val progress = part.progress.coerceIn(0L, 100L).toInt() + val notification = notification(text, progress, part.fileSize > 0L) + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification) + } + + fun onQueueChanged(hasWork: Boolean) { + if (!hasWork) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun startForegroundWithType(text: String) { + val notification = notification(text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { + val cancelIntent = PendingIntent.getService( + this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.icon) + .setContentTitle(DownloadServiceHost.notificationStrings(this).downloads) + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(100, progress, !determinate) + .addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + DownloadServiceHost.notificationStrings(this).downloads, + NotificationManager.IMPORTANCE_LOW)) + } + + private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + companion object { + private const val CHANNEL_ID = "downloads" + private const val NOTIFICATION_ID = 11 + private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL" + fun intent(context: Context) = Intent(context, DownloadService::class.java) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt new file mode 100644 index 00000000..38f64a74 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -0,0 +1,147 @@ +package com.audiobookshelf.app.services + +import android.content.Context +import androidx.core.content.ContextCompat +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.models.DownloadItem +import com.getcapacitor.JSObject +import java.util.Collections + +/** Shared process owner used by the foreground service and the Capacitor bridge. */ +object DownloadServiceHost { + data class NotificationStrings( + val preparing: String, + val downloadingFile: String, + val waitingForStorage: String, + val downloads: String, + val cancel: String + ) + + private var manager: DownloadItemManager? = null + private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter + private var service: DownloadService? = null + @Volatile private var bridgeReady = false + private val deferredCompletions = Collections.synchronizedList(mutableListOf()) + + @Synchronized + fun ensure(context: Context): DownloadItemManager { + if (manager == null) { + val appContext = context.applicationContext + DbManager.initialize(appContext) + manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) + manager!!.restoreQueue() + } + return manager!! + } + + /** Attaches the frontend after restored queue items have been emitted. */ + @Synchronized + fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { + bridgeReady = false + bridgeEmitter = emitter + val queue = ensure(context) + queue.setEventEmitter(ForwardingEmitter) + bridgeReady = true + val completions = synchronized(deferredCompletions) { + deferredCompletions.toList().also { deferredCompletions.clear() } + } + completions.forEach(bridgeEmitter::onDownloadItemComplete) + if (queue.hasWork()) startService(context) + } + + @Synchronized + fun detachBridge() { + bridgeReady = false + bridgeEmitter = NoopEmitter + } + + @Synchronized + fun enqueue(context: Context, item: DownloadItem) { + ensure(context).addDownloadItem(item) + startService(context) + } + + @Synchronized + fun cancelAll(context: Context) { ensure(context).cancelAll() } + + fun setNotificationStrings( + context: Context, + preparing: String, + downloadingFile: String, + waitingForStorage: String, + downloads: String, + cancel: String + ) { + context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREPARING, preparing) + .putString(KEY_DOWNLOADING_FILE, downloadingFile) + .putString(KEY_WAITING_FOR_STORAGE, waitingForStorage) + .putString(KEY_DOWNLOADS, downloads) + .putString(KEY_CANCEL, cancel) + .apply() + } + + fun notificationStrings(context: Context): NotificationStrings { + val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + return NotificationStrings( + preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING, + preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE) + ?: DEFAULT_DOWNLOADING_FILE, + preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE) + ?: DEFAULT_WAITING_FOR_STORAGE, + preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS, + preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL) + } + + @Synchronized + fun attachService(downloadService: DownloadService) { + service = downloadService + service?.onQueueChanged(ensure(downloadService).hasWork()) + } + + @Synchronized + fun detachService(downloadService: DownloadService) { + if (service === downloadService) service = null + } + + private fun startService(context: Context) { + ContextCompat.startForegroundService(context, DownloadService.intent(context)) + } + + private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) } + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) { + if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart) + service?.onPartUpdate(downloadItemPart) + } + override fun onDownloadItemComplete(jsobj: JSObject) { + if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj) + } + override fun onQueueChanged(hasWork: Boolean) { + bridgeEmitter.onQueueChanged(hasWork) + service?.onQueueChanged(hasWork) + } + } + + private object NoopEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) = Unit + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit + override fun onDownloadItemComplete(jsobj: JSObject) = Unit + override fun onQueueChanged(hasWork: Boolean) = Unit + } + + private const val NOTIFICATION_PREFERENCES = "download_notifications" + private const val KEY_PREPARING = "preparing" + private const val KEY_DOWNLOADING_FILE = "downloading_file" + private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage" + private const val KEY_DOWNLOADS = "downloads" + private const val KEY_CANCEL = "cancel" + private const val DEFAULT_PREPARING = "Preparing downloads" + private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}" + private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage" + private const val DEFAULT_DOWNLOADS = "Downloads" + private const val DEFAULT_CANCEL = "Cancel" +} diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue index 501e10cb..6ebd6436 100644 --- a/components/app/AudioPlayer.vue +++ b/components/app/AudioPlayer.vue @@ -85,22 +85,22 @@
- first_page -
+ first_page +
replay {{ jumpBackwardsLabel }}
- {{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }} + {{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}
-
+
forward_media {{ jumpForwardLabel }}
- last_page + last_page
@@ -111,7 +111,7 @@

{{ timeRemainingPretty }}

-
+
@@ -133,7 +133,7 @@ import { Capacitor } from '@capacitor/core' import { AbsAudioPlayer } from '@/plugins/capacitor' import { Dialog } from '@capacitor/dialog' -import { FastAverageColor } from 'fast-average-color' +import { getAverageColorFromCoverUrl } from '@/utils/coverAverageColor' import WrappingMarquee from '@/assets/WrappingMarquee.js' import jumpLabelMixin from '@/mixins/jumpLabel' @@ -176,6 +176,7 @@ export default { lockUi: false }, isLoading: false, + isCheckingServerProgress: false, isDraggingCursor: false, draggingTouchStartX: 0, draggingTouchStartTime: 0, @@ -289,6 +290,9 @@ export default { return 190 * heightScale } }, + showLoadingState() { + return this.isLoading || this.isCheckingServerProgress + }, showCastBtn() { return this.$store.state.isCastAvailable }, @@ -427,17 +431,14 @@ export default { }, async coverImageLoaded(fullCoverUrl) { if (!fullCoverUrl) return - - const fac = new FastAverageColor() - fac - .getColorAsync(fullCoverUrl) - .then((color) => { - this.coverRgb = color.rgba - this.coverBgIsLight = color.isLight - }) - .catch((e) => { - console.log(e) - }) + const avg = await getAverageColorFromCoverUrl(this, fullCoverUrl) + if (!avg) { + this.coverRgb = 'rgb(55, 56, 56)' + this.coverBgIsLight = false + } else { + this.coverRgb = avg.rgba + this.coverBgIsLight = avg.isLight + } }, clickTitleAndAuthor() { if (!this.showFullscreen) return @@ -480,13 +481,13 @@ export default { }, async jumpNextChapter() { await this.$hapticsImpact() - if (this.isLoading) return + if (this.showLoadingState) return if (!this.nextChapter) return this.seek(this.nextChapter.start) }, async jumpChapterStart() { await this.$hapticsImpact() - if (this.isLoading) return + if (this.showLoadingState) return if (!this.currentChapter) { return this.restart() } @@ -516,12 +517,12 @@ export default { }, async jumpBackwards() { await this.$hapticsImpact() - if (this.isLoading) return + if (this.showLoadingState) return AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime }) }, async jumpForward() { await this.$hapticsImpact() - if (this.isLoading) return + if (this.showLoadingState) return AbsAudioPlayer.seekForward({ value: this.jumpForwardTime }) }, setStreamReady() { @@ -625,7 +626,7 @@ export default { } }, seek(time) { - if (this.isLoading) return + if (this.showLoadingState) return if (this.seekLoading) { console.error('Already seek loading', this.seekedTime) return @@ -634,7 +635,8 @@ export default { this.seekedTime = time this.seekLoading = true - AbsAudioPlayer.seek({ value: Math.floor(time) }) + // Pass fractional seconds so seeks to non-integer chapter starts don't truncate + AbsAudioPlayer.seek({ value: time }) if (this.$refs.playedTrack) { const perc = time / this.totalDuration @@ -657,11 +659,14 @@ export default { }, async playPauseClick() { await this.$hapticsImpact() - if (this.isLoading) return + if (this.showLoadingState) return this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing this.isEnded = false }, + setIsCheckingServerProgress(value) { + this.isCheckingServerProgress = !!value + }, play() { AbsAudioPlayer.playPlayer() this.startPlayInterval() diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue index 1040e61b..f9f84047 100644 --- a/components/app/AudioPlayerContainer.vue +++ b/components/app/AudioPlayerContainer.vue @@ -48,6 +48,9 @@ export default { }, isIos() { return this.$platform === 'ios' + }, + currentPlaybackSession() { + return this.$store.state.currentPlaybackSession } }, methods: { @@ -226,7 +229,7 @@ export default { console.log('Already streaming item', startTime) if (startTime !== undefined && startTime !== null) { // seek to start time - AbsAudioPlayer.seek({ value: Math.floor(startTime) }) + AbsAudioPlayer.seek({ value: startTime }) } else if (this.$refs.audioPlayer) { this.$refs.audioPlayer.play() } @@ -304,48 +307,129 @@ export default { this.$refs.audioPlayer?.seek(currentTime) }, /** - * When device gains focus then refresh the timestamps in the audio player + * Fetch the current user's media progress from the server for a given library item / episode. + * Returns the server media progress object, or null if the request fails, times out, or the + * response doesn't match the requested library item. + * + * The audio player's loading state is shown while the request is in flight so the user + * doesn't tap play before we have a chance to update the timestamps. The request timeout + * is 7 seconds so a slow/unresponsive server doesn't block the user for long. */ - deviceFocused(hasFocus) { - if (!this.$store.state.currentPlaybackSession) return + async getServerMediaProgressForCurrentSession() { + if (!this.$store.state.user.user || !this.$store.state.networkConnected) return null + const libraryItemId = this.currentPlaybackSession?.libraryItemId + const episodeId = this.currentPlaybackSession?.episodeId + if (!libraryItemId) return null - if (hasFocus) { + if (this.$refs.audioPlayer?.isCheckingServerProgress) { + console.log('[AudioPlayerContainer] getServerMediaProgressForCurrentSession: already checking server progress') + return null + } + + const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}` + + this.$refs.audioPlayer?.setIsCheckingServerProgress(true) + try { + const data = await this.$nativeHttp.get(url, { connectTimeout: 7000, readTimeout: 7000 }) + if (!data || data.libraryItemId !== libraryItemId) return null + return data + } catch (error) { + console.error('[AudioPlayerContainer] Failed to get server media progress', error) + return null + } finally { + this.$refs.audioPlayer?.setIsCheckingServerProgress(false) + } + }, + getLocalMediaProgressForCurrentSession() { + if (!this.currentPlaybackSession) return null + return this.$store.getters['globals/getLocalMediaProgressById'](this.currentPlaybackSession.localLibraryItem?.id, this.currentPlaybackSession.localEpisodeId) + }, + /** + * Sync the server media progress with the local media progress + */ + async syncServerMediaProgressWithLocalMediaProgress(localMediaProgressId, serverMediaProgress) { + try { + const newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress({ + localMediaProgressId, + mediaProgress: serverMediaProgress + }) + if (newLocalMediaProgress?.id) { + this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress) + } + } catch (error) { + console.error('[AudioPlayerContainer] Failed to sync server progress with local media progress', error) + } + }, + /** + * Check if the server media progress is more recent than the local media progress and sync if so + */ + async checkSyncServerProgressWithLocalProgress(localMediaProgress) { + if (!localMediaProgress) return + console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: checking server media progress for local media item open in player') + const serverMediaProgress = await this.getServerMediaProgressForCurrentSession() + if (!serverMediaProgress?.lastUpdate || serverMediaProgress.lastUpdate <= localMediaProgress.lastUpdate) return + + console.log('[AudioPlayerContainer] checkSyncServerProgressWithLocalProgress: server progress is more recent than local progress. Server current time:', serverMediaProgress.currentTime, 'vs local', localMediaProgress.currentTime, `(server lastUpdate=${serverMediaProgress.lastUpdate} > local lastUpdate=${localMediaProgress.lastUpdate})`) + if (!this.$refs.audioPlayer?.isPlaying && serverMediaProgress.currentTime !== localMediaProgress.currentTime) { + // Use seek() so the native audio player's current session is updated + this.$refs.audioPlayer.seek(serverMediaProgress.currentTime) + } + + await this.syncServerMediaProgressWithLocalMediaProgress(localMediaProgress.id, serverMediaProgress) + }, + /** + * When socket is reconnected after a delay, if a local media item is open in the player (paused) + * we fetch the server media progress and sync it if it is more recent than the local progress + * + * If there is no socket connection we may have missed external progress updates + */ + async socketReconnected() { + if (!this.currentPlaybackSession) return + // dont update timestamps if player is playing + if (this.$refs.audioPlayer?.isPlaying) return + + if (this.$refs.audioPlayer.isLocalPlayMethod) { + const localMediaProgress = this.getLocalMediaProgressForCurrentSession() + if (!localMediaProgress) { + console.error('[AudioPlayerContainer] socket reconnected: Local media progress not found') + return + } + + await this.checkSyncServerProgressWithLocalProgress(localMediaProgress) + } + }, + /** + * When device re-gains focus then refresh the timestamps in the audio player + * if local item is open then fetch the server media progress and update if more recent + */ + async deviceFocused(hasFocus) { + if (!this.currentPlaybackSession || !hasFocus) return + // dont update timestamps if player is playing + if (this.$refs.audioPlayer?.isPlaying) return + + if (this.$refs.audioPlayer.isLocalPlayMethod) { + const localMediaProgress = this.getLocalMediaProgressForCurrentSession() + if (!localMediaProgress) { + console.error('[AudioPlayerContainer] device visibility: Local media progress not found') + return + } + + console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime) + this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime + this.$refs.audioPlayer.timeupdate() + + await this.checkSyncServerProgressWithLocalProgress(localMediaProgress) + } else { + // server item so fetch server media progress and update player time + console.log('[AudioPlayerContainer] device visibility: checking server media progress for server media item open in player') + const data = await this.getServerMediaProgressForCurrentSession() + if (!data) return if (!this.$refs.audioPlayer?.isPlaying) { - const playbackSession = this.$store.state.currentPlaybackSession - if (this.$refs.audioPlayer.isLocalPlayMethod) { - const localLibraryItemId = playbackSession.localLibraryItem?.id - const localEpisodeId = playbackSession.localEpisodeId - if (!localLibraryItemId) { - console.error('[AudioPlayerContainer] device visibility: no local library item for session', JSON.stringify(playbackSession)) - return - } - const localMediaProgress = this.$store.state.globals.localMediaProgress.find((mp) => { - if (localEpisodeId) return mp.localEpisodeId === localEpisodeId - return mp.localLibraryItemId === localLibraryItemId - }) - if (localMediaProgress) { - console.log('[AudioPlayerContainer] device visibility: found local media progress', localMediaProgress.currentTime, 'last time in player is', this.currentTime) - this.$refs.audioPlayer.currentTime = localMediaProgress.currentTime - this.$refs.audioPlayer.timeupdate() - } else { - console.error('[AudioPlayerContainer] device visibility: Local media progress not found') - } - } else { - const libraryItemId = playbackSession.libraryItemId - const episodeId = playbackSession.episodeId - const url = episodeId ? `/api/me/progress/${libraryItemId}/${episodeId}` : `/api/me/progress/${libraryItemId}` - this.$nativeHttp - .get(url) - .then((data) => { - if (!this.$refs.audioPlayer?.isPlaying && data.libraryItemId === libraryItemId) { - console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime) - this.$refs.audioPlayer.currentTime = data.currentTime - this.$refs.audioPlayer.timeupdate() - } - }) - .catch((error) => { - console.error('[AudioPlayerContainer] device visibility: Failed to get progress', error) - }) + console.log('[AudioPlayerContainer] device visibility: got server media progress', data.currentTime, 'last time in player is', this.currentTime) + // Only seek if the difference is greater than 1 second + if (Math.abs(data.currentTime - this.currentTime) > 1) { + // Use seek() so the native audio player's current session is updated + this.$refs.audioPlayer.seek(data.currentTime) } } } @@ -368,6 +452,7 @@ export default { this.$eventBus.$on('user-settings', this.settingsUpdated) this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate) this.$eventBus.$on('device-focus-update', this.deviceFocused) + this.$eventBus.$on('socket-reconnected', this.socketReconnected) }, beforeDestroy() { this.onLocalMediaProgressUpdateListener?.remove() @@ -383,6 +468,7 @@ export default { this.$eventBus.$off('user-settings', this.settingsUpdated) this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate) this.$eventBus.$off('device-focus-update', this.deviceFocused) + this.$eventBus.$off('socket-reconnected', this.socketReconnected) } } diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue index e4abd33b..24105626 100644 --- a/components/connection/ServerConnectForm.vue +++ b/components/connection/ServerConnectForm.vue @@ -484,7 +484,7 @@ export default { const { value } = await Dialog.confirm({ title: this.$strings.HeaderConfirm, - message: this.$strings.MessageConfirmDeleteServerConfig, + message: this.$strings.MessageConfirmDeleteServerConfig }) if (value) { this.processing = true @@ -843,7 +843,7 @@ export default { async setUserAndConnection({ user, userDefaultLibraryId, serverSettings, ereaderDevices }) { if (!user) return - console.log('Successfully logged in', JSON.stringify(user)) + console.log('Successfully logged in: ' + user.username) this.$store.commit('setServerSettings', serverSettings) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) diff --git a/components/widgets/DownloadProgressIndicator.vue b/components/widgets/DownloadProgressIndicator.vue index 8e389c7d..be1d502c 100644 --- a/components/widgets/DownloadProgressIndicator.vue +++ b/components/widgets/DownloadProgressIndicator.vue @@ -12,7 +12,8 @@ export default { return { downloadItemListener: null, completeListener: null, - itemPartUpdateListener: null + itemPartUpdateListener: null, + queueChangedListener: null } }, computed: { @@ -76,17 +77,22 @@ export default { }, onDownloadItemPartUpdate(itemPart) { this.$store.commit('globals/updateDownloadItemPart', itemPart) + }, + onQueueChanged(data) { + if (!data.hasWork) this.$store.commit('globals/clearItemDownloads') } }, async mounted() { this.downloadItemListener = await AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data)) this.itemPartUpdateListener = await AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data)) + this.queueChangedListener = await AbsDownloader.addListener('onQueueChanged', (data) => this.onQueueChanged(data)) this.completeListener = await AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data)) }, beforeDestroy() { this.downloadItemListener?.remove() this.completeListener?.remove() this.itemPartUpdateListener?.remove() + this.queueChangedListener?.remove() } } \ No newline at end of file diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 61eb36df..ad63bbaf 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -740,12 +740,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 0.11.0; + MARKETING_VERSION = 0.13.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -764,12 +764,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = Icons; CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 41; + CURRENT_PROJECT_VERSION = 43; DEVELOPMENT_TEAM = 7UFJ7D8V6A; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 0.11.0; + MARKETING_VERSION = 0.13.0; PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock index 1fc0fd35..fdd46c56 100644 --- a/ios/App/Podfile.lock +++ b/ios/App/Podfile.lock @@ -25,11 +25,11 @@ PODS: - Capacitor - CordovaPlugins (6.2.1): - CapacitorCordova - - Realm (10.54.4): - - Realm/Headers (= 10.54.4) - - Realm/Headers (10.54.4) - - RealmSwift (10.54.4): - - Realm (= 10.54.4) + - Realm (10.54.6): + - Realm/Headers (= 10.54.6) + - Realm/Headers (10.54.6) + - RealmSwift (10.54.6): + - Realm (= 10.54.6) - WebnativellcCapacitorFilesharer (7.0.4): - Capacitor @@ -89,22 +89,22 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 - Capacitor: 106e7a4205f4618d582b886a975657c61179138d - CapacitorApp: d63334c052278caf5d81585d80b21905c6f93f39 - CapacitorBrowser: 081852cf532acf77b9d2953f3a88fe5b9711fb06 - CapacitorClipboard: b98aead5dc7ec595547fc2c5d75bacd2ae3338bc - CapacitorCommunityKeepAwake: 00dfd8fa3cca0df003c9a3e2cd7bee678aeec68b - CapacitorCommunityVolumeButtons: 8a0443a202ed659688d85f4d44d66f42f62f2b56 + Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7 + CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9 + CapacitorBrowser: 6299776d496e968505464884d565992faa20444a + CapacitorClipboard: 70bfdb42b877b320a6e511ab94fa7a6a55d57ecb + CapacitorCommunityKeepAwake: ae762ce29b53147d28cfcaae5273cd1db0c38fc4 + CapacitorCommunityVolumeButtons: 1b84f7abf29cd9476cef9e8979b2854a64d2eed5 CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f - CapacitorDialog: 9b934329026b2b0ffa56939bb06df3c67541a2ab - CapacitorHaptics: 70e47470fa1a6bd6338cd102552e3846b7f9a1b3 - CapacitorNetwork: 07ec4c69c1bb696f41c23e00d31bda1bbb221bba - CapacitorPreferences: cbf154e5e5519b7f5ab33817a334dda1e98387f9 - CapacitorStatusBar: 275cbf2f4dfc00388f519ef80c7ec22edda342c9 - CordovaPlugins: 5a72a85b45469e68556bb172409f1b6d57b27236 - Realm: 8b5cda39a41f17a1734da2f39c6004eb8745587a - RealmSwift: 0b4f808fed6898f1f6c26f501f740efd80dff0b4 - WebnativellcCapacitorFilesharer: 10b111373d4dc49608935600dcbcc14605258c73 + CapacitorDialog: 0e09f242f6c3f5e82e4dc76b20f2a056be57a579 + CapacitorHaptics: 1f1e17041f435d8ead9ff2a34edd592c6aa6a8d6 + CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8 + CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94 + CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f + CordovaPlugins: 2ecbba09775516c41764dbf78ade612427311b7e + Realm: b1b3bc68162fa242132eb7eefbf91d7c40f36a85 + RealmSwift: 456cfd82a4f23dff8e3456980999331ab69bbf3e + WebnativellcCapacitorFilesharer: e3a5930240633db3335040251d66aac6762ff111 PODFILE CHECKSUM: 498821c0cfa2508609567fa95d7244c01cbef538 diff --git a/layouts/default.vue b/layouts/default.vue index aa621721..f7ea3885 100644 --- a/layouts/default.vue +++ b/layouts/default.vue @@ -24,6 +24,7 @@ export default { inittingLibraries: false, hasMounted: false, disconnectTime: 0, + socketDisconnectedTime: 0, timeLostFocus: 0, currentLang: null } @@ -44,7 +45,7 @@ export default { } else { var timeSinceDisconnect = Date.now() - this.disconnectTime if (timeSinceDisconnect > 5000) { - console.log('Time since disconnect was', timeSinceDisconnect, 'sync with server') + console.log('[default] Time since disconnect was', timeSinceDisconnect, 'sync with server') setTimeout(() => { this.syncLocalSessions(false) }, 4000) @@ -55,6 +56,28 @@ export default { this.disconnectTime = Date.now() } } + }, + socketConnected: { + handler(newVal, oldVal) { + if (!this.hasMounted) { + // watcher runs before mount, handling libraries/connection should be handled in mount + return + } + if (newVal) { + // if we havent been receiving socket events then external progress updates may have been missed + const timeSinceDisconnect = Date.now() - this.socketDisconnectedTime + if (timeSinceDisconnect > 30000 && this.isPlayerOpen) { + console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms and player is open, triggering server media progress sync') + // used for triggering a server media progress sync if local media item is open in player + this.$eventBus.$emit('socket-reconnected') + } else { + console.log('[default] socket reconnected after ' + timeSinceDisconnect + 'ms') + } + } else { + console.log('[default] socket disconnected') + this.socketDisconnectedTime = Date.now() + } + } } }, computed: { @@ -67,6 +90,9 @@ export default { networkConnected() { return this.$store.state.networkConnected }, + socketConnected() { + return this.$store.state.socketConnected + }, user() { return this.$store.state.user.user }, @@ -218,9 +244,9 @@ export default { AbsLogger.info({ tag: 'default', message: 'Calling syncLocalSessions' }) const response = await this.$db.syncLocalSessionsWithServer(isFirstSync) if (response?.error) { - console.error('[default] Failed to sync local sessions', response.error) + await AbsLogger.error({ tag: 'default', message: `syncLocalSessions: Failed to sync local sessions: ${response.error}` }) } else { - console.log('[default] Successfully synced local sessions') + await AbsLogger.info({ tag: 'default', message: 'syncLocalSessions: Successfully synced local sessions' }) // Reload local media progresses await this.$store.dispatch('globals/loadLocalMediaProgress') } @@ -233,11 +259,13 @@ export default { async userMediaProgressUpdated(payload) { const prog = payload.data // MediaProgress await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Received updated media progress for current user from socket event. Media item id ${payload.id}` }) + const mediaProgressId = payload.id + const itemLabel = `${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''}` // Check if this media item is currently open in the player, paused, and this progress update is coming from a different session const isMediaOpenInPlayer = this.$store.getters['getIsMediaStreaming'](prog.libraryItemId, prog.episodeId) if (isMediaOpenInPlayer && this.$store.getters['getCurrentPlaybackSessionId'] !== payload.sessionId && !this.$store.state.playerIsPlaying) { - await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Item is currently open in player, paused and this progress update is coming from a different session. Updating playback time to ${payload.data.currentTime}` }) + await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Updating paused player playback time to ${payload.data.currentTime} (${itemLabel}, mediaProgressId: ${mediaProgressId})` }) this.$eventBus.$emit('playback-time-update', payload.data.currentTime) } @@ -248,17 +276,17 @@ export default { // Progress update is more recent then local progress if (localProg && localProg.lastUpdate < prog.lastUpdate) { if (localProg.currentTime == prog.currentTime && localProg.isFinished == prog.isFinished) { - await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (libraryItemId: ${prog.libraryItemId}${prog.episodeId ? ` episodeId: ${prog.episodeId}` : ''})` }) + await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: server lastUpdate is more recent but progress is up-to-date (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` }) return } // Server progress is more up-to-date - await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: syncing progress from server with local item for "${prog.libraryItemId}" ${prog.episodeId ? `episode ${prog.episodeId}` : ''} | server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate}` }) - const payload = { + await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Syncing server progress to local (${itemLabel}, mediaProgressId: ${mediaProgressId}, server lastUpdate=${prog.lastUpdate} > local lastUpdate=${localProg.lastUpdate})` }) + const syncPayload = { localMediaProgressId: localProg.id, mediaProgress: prog } - newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload) + newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload) } else if (!localProg) { // Check if local library item exists // local media progress may not exist yet if it hasn't been played @@ -270,20 +298,20 @@ export default { const localEpisode = lliEpisodes.find((ep) => ep.serverEpisodeId === prog.episodeId) if (localEpisode) { // Add new local media progress - const payload = { + const syncPayload = { localLibraryItemId: localLibraryItem.id, localEpisodeId: localEpisode.id, mediaProgress: prog } - newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload) + newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload) } } else { // Add new local media progress - const payload = { + const syncPayload = { localLibraryItemId: localLibraryItem.id, mediaProgress: prog } - newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(payload) + newLocalMediaProgress = await this.$db.syncServerMediaProgressWithLocalMediaProgress(syncPayload) } } else { console.log(`[default] userMediaProgressUpdate no local media progress or lli found for this server item ${prog.id}`) @@ -291,7 +319,7 @@ export default { } if (newLocalMediaProgress?.id) { - await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: local media progress updated for ${newLocalMediaProgress.id}` }) + await AbsLogger.info({ tag: 'default', message: `userMediaProgressUpdate: Local media progress updated (${itemLabel}, localId: ${newLocalMediaProgress.id})` }) this.$store.commit('globals/updateLocalMediaProgress', newLocalMediaProgress) } }, diff --git a/package-lock.json b/package-lock.json index 2a3385a9..adf34e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-app", - "version": "0.12.0-beta", + "version": "0.13.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "audiobookshelf-app", - "version": "0.12.0-beta", + "version": "0.13.0-beta", "dependencies": { "@capacitor-community/keep-awake": "^7.0.0", "@capacitor-community/volume-buttons": "^7.0.0", diff --git a/package.json b/package.json index 4e4777e2..386f4da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-app", - "version": "0.12.0-beta", + "version": "0.13.0-beta", "author": "advplyr", "scripts": { "dev": "nuxt --hostname 0.0.0.0 --port 1337", diff --git a/pages/item/_id/index.vue b/pages/item/_id/index.vue index 4966eecf..3cec7392 100644 --- a/pages/item/_id/index.vue +++ b/pages/item/_id/index.vue @@ -13,7 +13,7 @@
- +
@@ -172,7 +172,7 @@