From d3b7ae5905eeb9ffd62f1bb4741f6f81adcb5f3d Mon Sep 17 00:00:00 2001 From: Emily Ehlert Date: Mon, 24 Jun 2024 10:38:24 +0200 Subject: [PATCH] Move media and subtitle download to ExoPlayer Download Service --- .../3.json | 20 +- app/src/main/AndroidManifest.xml | 13 + .../java/org/jellyfin/mobile/app/AppModule.kt | 57 ++++- .../jellyfin/mobile/data/dao/DownloadDao.kt | 8 +- .../mobile/data/entity/DownloadEntity.kt | 19 +- .../jellyfin/mobile/downloads/DownloadItem.kt | 5 +- .../mobile/downloads/DownloadProgress.kt | 48 ---- .../mobile/downloads/DownloadService.kt | 80 ++++++ .../mobile/downloads/DownloadServiceUtil.kt | 64 +++++ .../mobile/downloads/DownloadTracker.kt | 77 ++++++ .../mobile/downloads/DownloadUtils.kt | 232 +++++++----------- .../interaction/PlayerNotificationHelper.kt | 4 +- .../mobile/player/queue/QueueManager.kt | 7 +- .../org/jellyfin/mobile/utils/Constants.kt | 2 + .../org/jellyfin/mobile/utils/SystemUtils.kt | 23 +- 15 files changed, 432 insertions(+), 227 deletions(-) delete mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadProgress.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadService.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt create mode 100644 app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt diff --git a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json index 928f9909c..a044aca3a 100644 --- a/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json +++ b/app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 3, - "identityHash": "7557b2c520ecc7e54020a91aba6386b5", + "identityHash": "4366575e1609ecf3e2c44abd106d51d6", "entities": [ { "tableName": "Server", @@ -115,7 +115,7 @@ }, { "tableName": "Download", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `item_id` TEXT NOT NULL, `file_uri` TEXT NOT NULL, `media_source` TEXT NOT NULL, `thumbnail_uri` TEXT NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `item_id` TEXT NOT NULL, `media_uri` TEXT NOT NULL, `media_source` TEXT NOT NULL, `download_folder_uri` TEXT NOT NULL, `download_length` INTEGER NOT NULL)", "fields": [ { "fieldPath": "id", @@ -130,8 +130,8 @@ "notNull": true }, { - "fieldPath": "fileURI", - "columnName": "file_uri", + "fieldPath": "mediaUri", + "columnName": "media_uri", "affinity": "TEXT", "notNull": true }, @@ -142,10 +142,16 @@ "notNull": true }, { - "fieldPath": "thumbnail", - "columnName": "thumbnail_uri", + "fieldPath": "downloadFolderUri", + "columnName": "download_folder_uri", "affinity": "TEXT", "notNull": true + }, + { + "fieldPath": "downloadLength", + "columnName": "download_length", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -171,7 +177,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7557b2c520ecc7e54020a91aba6386b5')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4366575e1609ecf3e2c44abd106d51d6')" ] } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 778dcb889..1d71b3d6a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,9 +13,11 @@ android:required="false" android:usesPermissionFlags="neverForLocation" tools:targetApi="s" /> + + + + + + + + + + + { + single { + val dbProvider = StandaloneDatabaseProvider(get()) + dbProvider + } + single { + val downloadPath = File(get().filesDir, Constants.DOWNLOAD_PATH) + if (!downloadPath.exists()) { + downloadPath.mkdirs() + } + val cache = SimpleCache(downloadPath, NoOpCacheEvictor(), get()) + cache + } + + single { val context: Context = get() val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider -> @@ -105,7 +125,32 @@ val applicationModule = module { } DefaultDataSource.Factory(context, baseDataSourceFactory) + } + + single { + // Create a read-only cache data source factory using the download cache. + CacheDataSource.Factory() + .setCache(get()) + .setUpstreamDataSourceFactory(get()) + .setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR) + .setCacheKeyFactory { spec -> + val uri = spec.uri.toString() + val idRegex = Regex("""/([a-f0-9]{32}|[a-f0-9-]{36})/""") + val idResult = idRegex.find(uri) + val itemId = idResult?.groups?.get(1)?.value.toString() + var item = itemId.toUUID().toString() + + val subtitleRegex = Regex("""(?:Subtitles/(\d+)/\d+/Stream.subrip)|(?:/(\d+).subrip)""") + val subtitleResult = subtitleRegex.find(uri) + if (subtitleResult != null) { + item += ":${subtitleResult.groups?.get(1)?.value ?: subtitleResult.groups?.get(2)?.value}" + } + + item + } + } + single { val context: Context = get() val extractorsFactory = DefaultExtractorsFactory().apply { @@ -117,11 +162,11 @@ val applicationModule = module { }, ) } - DefaultMediaSourceFactory(get(), extractorsFactory) + DefaultMediaSourceFactory(get(), extractorsFactory) } - single { ProgressiveMediaSource.Factory(get()) } - single { HlsMediaSource.Factory(get()) } - single { SingleSampleMediaSource.Factory(get()) } + single { ProgressiveMediaSource.Factory(get()) } + single { HlsMediaSource.Factory(get()) } + single { SingleSampleMediaSource.Factory(get()) } // Media components single { LibraryBrowser(get(), get()) } diff --git a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt index 3eaaecb13..7434a5ce0 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt @@ -22,11 +22,11 @@ interface DownloadDao { @Query("SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId") suspend fun get(downloadId: String): DownloadEntity - @Query("SELECT thumbnail_uri FROM $TABLE_NAME WHERE item_id LIKE :downloadId") - suspend fun getThumbnailURI(downloadId: String): String + @Query("SELECT download_folder_uri FROM $TABLE_NAME WHERE item_id LIKE :downloadId") + suspend fun getDownloadFolderUri(downloadId: String): String - @Query("SELECT file_uri FROM $TABLE_NAME WHERE item_id LIKE :downloadId") - suspend fun getFileURI(downloadId: String): String + @Query("SELECT media_uri FROM $TABLE_NAME WHERE item_id LIKE :downloadId") + suspend fun getMediaUri(downloadId: String): String @Query("SELECT EXISTS(SELECT * FROM $TABLE_NAME WHERE item_id LIKE :downloadId)") suspend fun downloadExists(downloadId : String) : Boolean diff --git a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt index a4f7bcf95..d4e27a381 100644 --- a/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt +++ b/app/src/main/java/org/jellyfin/mobile/data/entity/DownloadEntity.kt @@ -19,22 +19,25 @@ data class DownloadEntity( val id: Long, @ColumnInfo(name = ITEM_ID) val itemId: String, - @ColumnInfo(name = FILE_URI) - val fileURI: String, + @ColumnInfo(name = MEDIA_URI) + val mediaUri: String, @ColumnInfo(name = MEDIA_SOURCE) val mediaSource: String, - @ColumnInfo(name = THUMBNAIL_URI) - val thumbnail: String + @ColumnInfo(name = DOWNLOAD_FOLDER_URI) + val downloadFolderUri: String, + @ColumnInfo(name = DOWNLOAD_LENGTH) + val downloadLength: Long ) { - constructor(itemId: String, fileURI: String, mediaSource: String, thumbnailURI: String) : - this(0, itemId, fileURI, mediaSource, thumbnailURI) + constructor(itemId: String, mediaUri: String, mediaSource: String, downloadFolderUri: String, downloadLength: Long) : + this(0, itemId, mediaUri, mediaSource, downloadFolderUri, downloadLength) companion object Key { const val TABLE_NAME = "Download" const val ID = "id" const val ITEM_ID = "item_id" - const val FILE_URI = "file_uri" + const val MEDIA_URI = "media_uri" const val MEDIA_SOURCE = "media_source" - const val THUMBNAIL_URI = "thumbnail_uri" + const val DOWNLOAD_FOLDER_URI = "download_folder_uri" + const val DOWNLOAD_LENGTH = "download_length" } } diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt index 2e9b36292..ff158c39c 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadItem.kt @@ -5,13 +5,14 @@ import android.graphics.BitmapFactory import kotlinx.serialization.json.Json import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.player.source.JellyfinMediaSource +import org.jellyfin.mobile.utils.Constants import java.io.File import java.util.Locale data class DownloadItem (private val download: DownloadEntity) { val mediaSource: JellyfinMediaSource = Json.decodeFromString(download.mediaSource) - val thumbnail: Bitmap? = BitmapFactory.decodeFile(download.thumbnail) - val fileSize: String = formatFileSize(File(download.fileURI).length()) + val thumbnail: Bitmap? = BitmapFactory.decodeFile(File(download.downloadFolderUri, Constants.DOWNLOAD_THUMBNAIL_FILENAME).canonicalPath) + val fileSize: String = formatFileSize(download.downloadLength) private fun formatFileSize(bytes: Long): String { if (bytes < 1024) return "$bytes B" diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadProgress.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadProgress.kt deleted file mode 100644 index b412e7e07..000000000 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadProgress.kt +++ /dev/null @@ -1,48 +0,0 @@ -package org.jellyfin.mobile.downloads - -import okhttp3.MediaType -import okhttp3.ResponseBody -import okio.Buffer -import okio.BufferedSource -import okio.ForwardingSource -import okio.Source -import okio.buffer -import java.io.IOException - -class ProgressResponseBody(private val responseBody: ResponseBody?, private val progressListener: ProgressListener) : ResponseBody() { - private var bufferedSource: BufferedSource? = null - - override fun contentType(): MediaType? { - return responseBody?.contentType() - } - - override fun contentLength(): Long { - return responseBody?.contentLength() ?: -1L - } - - override fun source(): BufferedSource { - if (bufferedSource == null) { - bufferedSource = source(responseBody!!.source()).buffer() - } - return bufferedSource!! - } - - private fun source(source: Source): Source { - return object : ForwardingSource(source) { - var totalBytesRead: Long = 0L - - @Throws(IOException::class) - override fun read(sink: Buffer, byteCount: Long): Long { - val bytesRead = super.read(sink, byteCount) - // read() returns the number of bytes read, or -1 if this source is exhausted. - totalBytesRead += if (bytesRead != -1L) bytesRead else 0 - progressListener.update(totalBytesRead, responseBody?.contentLength() ?: -1L, bytesRead == -1L) - return bytesRead - } - } - } -} - -interface ProgressListener { - fun update(bytesRead: Long, contentLength: Long, done: Boolean) -} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadService.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadService.kt new file mode 100644 index 000000000..a75aceff3 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadService.kt @@ -0,0 +1,80 @@ +package org.jellyfin.mobile.downloads + +import android.app.Notification +import android.content.Context +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.PlatformScheduler +import com.google.android.exoplayer2.scheduler.Scheduler +import com.google.android.exoplayer2.ui.DownloadNotificationHelper +import com.google.android.exoplayer2.util.NotificationUtil +import com.google.android.exoplayer2.util.Util +import org.jellyfin.mobile.R +import org.jellyfin.mobile.utils.Constants + +class JellyfinDownloadService : DownloadService(Constants.DOWNLOAD_NOTIFICATION_ID, DEFAULT_FOREGROUND_NOTIFICATION_UPDATE_INTERVAL) { + private val jobId = 1 + + override fun getDownloadManager(): DownloadManager { + val downloadManager: DownloadManager = DownloadServiceUtil.getDownloadManager() + val downloadNotificationHelper: DownloadNotificationHelper = + DownloadServiceUtil.getDownloadNotificationHelper(this) + downloadManager.addListener( + TerminalStateNotificationHelper( + this, downloadNotificationHelper, Constants.DOWNLOAD_NOTIFICATION_ID + 1, + ), + ) + return downloadManager + } + + override fun getScheduler(): Scheduler? { + return if (Util.SDK_INT >= 21) PlatformScheduler(this, jobId) else null + } + + override fun getForegroundNotification(downloads: MutableList, notMetRequirements: Int): Notification { + return DownloadServiceUtil.getDownloadNotificationHelper(this) + .buildProgressNotification( + this, + R.drawable.ic_notification, + null, + if (downloads.isEmpty()) null else Util.fromUtf8Bytes(downloads[0].request.data), + downloads, + notMetRequirements, + ) + } + + private class TerminalStateNotificationHelper(context: Context, private val notificationHelper: DownloadNotificationHelper, private var nextNotificationId: Int) : DownloadManager.Listener { + private val context: Context = context.applicationContext + + override fun onDownloadChanged( + downloadManager: DownloadManager, download: Download, finalException: Exception?, + ) { + if (download.request.data.isEmpty()) { + // Do not display download complete notification for external subtitles + // Can be identified by request data being empty + return + } + val notification = when (download.state) { + Download.STATE_COMPLETED -> { + notificationHelper.buildDownloadCompletedNotification( + context, + R.drawable.ic_notification, + null, + Util.fromUtf8Bytes(download.request.data), + ) + } + Download.STATE_FAILED -> { + notificationHelper.buildDownloadFailedNotification( + context, + R.drawable.ic_notification, + null, + Util.fromUtf8Bytes(download.request.data), + ) + } + else -> return + } + NotificationUtil.setNotification(context, nextNotificationId++, notification) + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt new file mode 100644 index 000000000..1c9b85471 --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadServiceUtil.kt @@ -0,0 +1,64 @@ +package org.jellyfin.mobile.downloads + +import android.content.Context +import com.google.android.exoplayer2.database.DatabaseProvider +import com.google.android.exoplayer2.offline.DefaultDownloadIndex +import com.google.android.exoplayer2.offline.DefaultDownloaderFactory +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.android.exoplayer2.ui.DownloadNotificationHelper +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.concurrent.Executors + +object DownloadServiceUtil : KoinComponent { + + private val context: Context by inject() + private val databaseProvider: DatabaseProvider by inject() + private val downloadDataCache: CacheDataSource.Factory by inject() + private var downloadManager: DownloadManager? = null + private var downloadNotificationHelper: DownloadNotificationHelper? = null + private var downloadTracker: DownloadTracker? = null + + @Synchronized + fun getDownloadNotificationHelper( + context: Context?, + ): DownloadNotificationHelper { + if (downloadNotificationHelper == null) { + downloadNotificationHelper = + DownloadNotificationHelper(context!!, DOWNLOAD_NOTIFICATION_CHANNEL_ID) + } + return downloadNotificationHelper!! + } + + @Synchronized + fun getDownloadManager(): DownloadManager { + ensureDownloadManagerInitialized(context) + return downloadManager!! + } + + @Synchronized + fun getDownloadTracker(): DownloadTracker { + ensureDownloadManagerInitialized(context) + return downloadTracker!! + } + + @Synchronized + private fun ensureDownloadManagerInitialized(context: Context) { + if (downloadManager == null) { + downloadManager = + DownloadManager( + context, + DefaultDownloadIndex(databaseProvider), + DefaultDownloaderFactory( + downloadDataCache, + Executors.newFixedThreadPool(6), + ), + ) + downloadTracker = + DownloadTracker(downloadManager!!) + + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt new file mode 100644 index 000000000..5e5aa0a6d --- /dev/null +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadTracker.kt @@ -0,0 +1,77 @@ +package org.jellyfin.mobile.downloads + +import android.net.Uri +import com.google.android.exoplayer2.offline.Download +import com.google.android.exoplayer2.offline.DownloadIndex +import com.google.android.exoplayer2.offline.DownloadManager +import com.google.common.base.Preconditions +import java.io.IOException +import java.util.concurrent.CopyOnWriteArraySet + +class DownloadTracker(downloadManager: DownloadManager) { + interface Listener { + fun onDownloadsChanged() + } + + private val listeners: CopyOnWriteArraySet = CopyOnWriteArraySet() + private val downloads: HashMap = HashMap() + private val downloadIndex: DownloadIndex + + + init { + downloadIndex = downloadManager.downloadIndex + downloadManager.addListener(DownloadManagerListener()) + loadDownloads() + } + + fun addListener(listener: Listener?) { + listeners.add(Preconditions.checkNotNull(listener)) + } + + fun removeListener(listener: Listener) { + listeners.remove(listener) + } + + fun isDownloaded(uri: Uri): Boolean { + val download = downloads[uri] + return download != null && download.state == Download.STATE_COMPLETED + } + + fun getDownloadSize(uri: Uri): Long { + val download = downloads[uri] + return download?.bytesDownloaded ?: 0 + } + + fun isFailed(uri: Uri): Boolean { + val download = downloads[uri] + return download != null && download.state == Download.STATE_FAILED + } + + private fun loadDownloads() { + try { + downloadIndex.getDownloads().use { loadedDownloads -> + while (loadedDownloads.moveToNext()) { + val download = loadedDownloads.download + downloads[download.request.uri] = download + } + } + } catch (e: IOException) { + } + } + + private inner class DownloadManagerListener : DownloadManager.Listener { + override fun onDownloadChanged(downloadManager: DownloadManager, download: Download, finalException: Exception?) { + downloads[download.request.uri] = download + for (listener in listeners) { + listener.onDownloadsChanged() + } + } + + override fun onDownloadRemoved(downloadManager: DownloadManager, download: Download) { + downloads.remove(download.request.uri) + for (listener in listeners) { + listener.onDownloadsChanged() + } + } + } +} diff --git a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt index 9b25ca4a7..5d9bcfab1 100644 --- a/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/downloads/DownloadUtils.kt @@ -13,27 +13,21 @@ import android.net.Network import android.net.NetworkCapabilities import android.net.Uri import android.os.Build -import android.os.SystemClock.sleep import android.util.AndroidException -import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri -import androidx.lifecycle.lifecycleScope import coil.ImageLoader import coil.request.ImageRequest +import com.google.android.exoplayer2.offline.DownloadRequest +import com.google.android.exoplayer2.offline.DownloadService +import com.google.android.exoplayer2.scheduler.Requirements import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import okhttp3.Interceptor -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okio.BufferedSink -import okio.BufferedSource import okio.buffer import okio.sink import org.jellyfin.mobile.MainActivity @@ -46,7 +40,6 @@ import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.player.source.MediaSourceResolver import org.jellyfin.mobile.utils.AndroidVersion import org.jellyfin.mobile.utils.Constants -import org.jellyfin.mobile.utils.Constants.DOWNLOAD_NOTIFICATION_ID import org.jellyfin.mobile.utils.requestPermission import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi @@ -65,24 +58,23 @@ import kotlin.coroutines.suspendCoroutine class DownloadUtils(val context: Context, private val filename: String, private val downloadURL: String, private val downloadMethod: Int) : KoinComponent { private val mainActivity: MainActivity = context as MainActivity - private val itemFolder: File + private val downloadFolder: File private val itemId: String private val itemUUID: UUID + private val contentId: String private val downloadDao: DownloadDao by inject() private val apiClient: ApiClient = get() private val imageLoader: ImageLoader by inject() private val mediaSourceResolver: MediaSourceResolver by inject() private val deviceProfileBuilder: DeviceProfileBuilder by inject() private val deviceProfile = deviceProfileBuilder.getDeviceProfile() - private val okClient: OkHttpClient private val notificationManager: NotificationManager? by lazy { context.getSystemService() } - private lateinit var notificationBuilder: NotificationCompat.Builder private val connectivityManager: ConnectivityManager? by lazy { context.getSystemService() } private val appPreferences: AppPreferences by inject() + private var downloadTracker: DownloadTracker + private val jellyfinDownloadTracker: DownloadUtils.JellyfinDownloadTracker = JellyfinDownloadTracker() - private var thumbnailURI: String = "" private var jellyfinMediaSource: JellyfinMediaSource? = null - private val progressListener: ProgressListener init { @@ -90,66 +82,43 @@ class DownloadUtils(val context: Context, private val filename: String, private val matchResult = regex.find(downloadURL) itemId = matchResult?.groups?.get(1)?.value.toString() itemUUID = itemId.toUUID() - itemFolder = File(context.filesDir, "/Downloads/$itemId/") - itemFolder.mkdirs() - - - progressListener = object : ProgressListener { - var prevProgress = 0L - - override fun update(bytesRead: Long, contentLength: Long, done: Boolean) { - val progress = 100 * bytesRead / contentLength - if (contentLength != -1L && progress > prevProgress) { - mainActivity.lifecycleScope.launch { - withContext(Dispatchers.Main) { - notificationBuilder.setProgress(100, progress.toInt(), false) - notificationManager?.notify(DOWNLOAD_NOTIFICATION_ID, notificationBuilder.build()) - } - } - prevProgress = progress - } - } - } - - okClient = OkHttpClient.Builder() - .addNetworkInterceptor( - Interceptor { chain: Interceptor.Chain -> - val originalResponse: Response = chain.proceed(chain.request()) - originalResponse.newBuilder() - .body(ProgressResponseBody(originalResponse.body, progressListener)) - .build() - }, - ) - .build() + contentId = itemUUID.toString() + downloadFolder = File(context.filesDir, "/Downloads/$itemId/") + downloadFolder.mkdirs() + downloadTracker = DownloadServiceUtil.getDownloadTracker() } suspend fun download() { - try { - createDownloadNotification() - checkForDownloadMethod() - retrieveJellyfinMediaSource() - val internalDownload = getDownloadLocation() - if (internalDownload) { + createDownloadNotificationChannel() + checkForDownloadMethod() + retrieveJellyfinMediaSource() + val internalDownload = getDownloadLocation() + if (internalDownload) { + try { checkIfDownloadExists() - addTitleToNotification() downloadFiles() - storeDownloadSpecs() - completeDownloadNotification() - } else { - downloadExternalMediaFile() + } catch (e: IOException) { + removeDownloadRemains() } - } catch (e: IOException) { - itemFolder.deleteRecursively() - notifyFailedDownload(e) + } else { + downloadExternalMediaFile() } } @SuppressLint("InlinedApi") @Suppress("Deprecation") private fun checkForDownloadMethod() { - // ToDo: Rework Download Methods val validConnection = when (downloadMethod) { - DownloadMethod.WIFI_ONLY -> ! (connectivityManager?.isActiveNetworkMetered ?: false) + DownloadMethod.WIFI_ONLY -> { + val downloadRequirements = Requirements(Requirements.NETWORK_UNMETERED) + DownloadService.sendSetRequirements( + context, + JellyfinDownloadService::class.java, + downloadRequirements, + false + ) + ! (connectivityManager?.isActiveNetworkMetered ?: false) + } DownloadMethod.MOBILE_DATA -> { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { ! (connectivityManager?.activeNetworkInfo?.isRoaming ?: throw AndroidException()) @@ -188,29 +157,20 @@ class DownloadUtils(val context: Context, private val filename: String, private } private suspend fun downloadFiles() { + downloadTracker.addListener(jellyfinDownloadTracker) downloadMediaFile() downloadThumbnail() downloadExternalSubtitles() } - private suspend fun downloadMediaFile() { - withContext(Dispatchers.IO) { - val request = Request.Builder() - .url(downloadURL) - .build() - - okClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw IOException(context.getString(R.string.failed_media)) - - val downloadFile = File(itemFolder, filename) - val sink: BufferedSink = downloadFile.sink().buffer() - val source: BufferedSource = response.body!!.source() - - sink.writeAll(source) - sink.close() - //ToDo: Fix download not be properly aborted when connection is severed - } - } + private fun downloadMediaFile() { + val downloadRequest = DownloadRequest.Builder(contentId, downloadURL.toUri()).setData(jellyfinMediaSource!!.item!!.name!!.encodeToByteArray()).build() + DownloadService.sendAddDownload( + context, + JellyfinDownloadService::class.java, + downloadRequest, + false + ) } private suspend fun downloadThumbnail() { @@ -225,46 +185,37 @@ class DownloadUtils(val context: Context, private val filename: String, private val imageRequest = ImageRequest.Builder(context).data(imageUrl).build() val bitmap: Bitmap = imageLoader.execute(imageRequest).drawable?.toBitmap() ?: throw IOException(context.getString(R.string.failed_thumbnail)) - val thumbnailFile = File(itemFolder, "thumbnail.jpg") + val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME) val sink = thumbnailFile.sink().buffer() bitmap.compress(Bitmap.CompressFormat.JPEG, 80, sink.outputStream()) withContext(Dispatchers.IO) { sink.close() } - thumbnailURI = thumbnailFile.canonicalPath } - private suspend fun downloadExternalSubtitles() { + private fun downloadExternalSubtitles() { jellyfinMediaSource!!.externalSubtitleStreams.forEach { - withContext(Dispatchers.IO) { - val subtitleDownloadURL: String = apiClient.createUrl(it.deliveryUrl) - val request = Request.Builder() - .url(subtitleDownloadURL) - .build() - - okClient.newCall(request).execute().use { response -> - if (!response.isSuccessful) throw IOException(context.getString(R.string.failed_subs)) - - val downloadFile = File(itemFolder, "${it.index}.subrip") - val sink: BufferedSink = downloadFile.sink().buffer() - val source: BufferedSource = response.body!!.source() - sink.writeAll(source) - sink.close() - } - } + val subtitleDownloadURL: String = apiClient.createUrl(it.deliveryUrl) + val downloadRequest = DownloadRequest.Builder("${contentId}:${it.index}", subtitleDownloadURL.toUri()).build() + DownloadService.sendAddDownload( + context, + JellyfinDownloadService::class.java, + downloadRequest, + false + ) } } private suspend fun storeDownloadSpecs() { val serializedJellyfinMediaSource = Json.encodeToString(jellyfinMediaSource) - val downloadFile = File(itemFolder, filename) downloadDao.insert( DownloadEntity( itemId = itemId, - fileURI = downloadFile.canonicalPath, + mediaUri = downloadURL, mediaSource = serializedJellyfinMediaSource, - thumbnailURI = thumbnailURI, - ), + downloadFolderUri = downloadFolder.canonicalPath, + downloadLength = downloadTracker.getDownloadSize(downloadURL.toUri()) + ) ) } @@ -293,48 +244,25 @@ class DownloadUtils(val context: Context, private val filename: String, private context.getSystemService()?.enqueue(downloadRequest) } - private suspend fun createDownloadNotification() { - createDownloadNotificationChannel() - - notificationBuilder = NotificationCompat.Builder(context, Constants.DOWNLOAD_NOTIFICATION_CHANNEL_ID).apply { - setSmallIcon(R.drawable.ic_notification) - setContentTitle(context.getString(R.string.downloading)) - setOngoing(true) - setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - } - - withContext(Dispatchers.Main) { - notificationManager?.notify(DOWNLOAD_NOTIFICATION_ID, notificationBuilder.build()) - } - } - - private suspend fun notifyFailedDownload(exception: IOException) { - notificationBuilder.apply { - setContentTitle(context.getString(R.string.download_failed)) - setContentText(exception.message ?: "") - } - withContext(Dispatchers.Main) { - notificationManager?.notify(DOWNLOAD_NOTIFICATION_ID, notificationBuilder.build()) - } - } + private fun removeDownloadRemains() { + downloadFolder.deleteRecursively() - private suspend fun addTitleToNotification() { - notificationBuilder.setContentText("${context.getString(R.string.downloading)} ${jellyfinMediaSource?.name}") - withContext(Dispatchers.Main) { - notificationManager?.notify(DOWNLOAD_NOTIFICATION_ID, notificationBuilder.build()) - } - } + // Remove media file + DownloadService.sendRemoveDownload( + context, + JellyfinDownloadService::class.java, + contentId, + false + ) - private suspend fun completeDownloadNotification() { - notificationBuilder.apply { - setContentTitle(context.getString(R.string.download_finished)) - setContentText(context.getString(R.string.downloaded, jellyfinMediaSource?.name)) - setProgress(0, 0, false) - setOngoing(false) - } - withContext(Dispatchers.Main) { - sleep(500) // Wait for progress notifications to be displayed, otherwise might be overridden - notificationManager?.notify(DOWNLOAD_NOTIFICATION_ID, notificationBuilder.build()) + // Remove subtitles + jellyfinMediaSource!!.externalSubtitleStreams.forEach { + DownloadService.sendRemoveDownload( + context, + JellyfinDownloadService::class.java, + "${contentId}:${it.index}", + false + ) } } @@ -350,4 +278,20 @@ class DownloadUtils(val context: Context, private val filename: String, private notificationManager?.createNotificationChannel(notificationChannel) } } + + private inner class JellyfinDownloadTracker : DownloadTracker.Listener { + override fun onDownloadsChanged() { + if (downloadTracker.isDownloaded(downloadURL.toUri())) { + runBlocking { + withContext(Dispatchers.IO) { + storeDownloadSpecs() + } + } + downloadTracker.removeListener(this) + } else if (downloadTracker.isFailed(downloadURL.toUri())) { + removeDownloadRemains() + downloadTracker.removeListener(this) + } + } + } } diff --git a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt index 3557ef776..5554bb65d 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/interaction/PlayerNotificationHelper.kt @@ -149,8 +149,8 @@ class PlayerNotificationHelper(private val viewModel: PlayerViewModel) : KoinCom private suspend fun loadImage(mediaSource: JellyfinMediaSource): Bitmap? { if (mediaSource.isDownload) { - val thumbnailURI = downloadDao.getThumbnailURI(mediaSource.id) - val thumbnailFile = File(thumbnailURI) + val downloadFolder = File(downloadDao.getDownloadFolderUri(mediaSource.id)) + val thumbnailFile = File(downloadFolder, Constants.DOWNLOAD_THUMBNAIL_FILENAME) return BitmapFactory.decodeFile(thumbnailFile.canonicalPath) } else { val size = context.resources.getDimensionPixelSize(R.dimen.media_notification_height) diff --git a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt index 0ede2c2c1..c249c0ecd 100644 --- a/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt +++ b/app/src/main/java/org/jellyfin/mobile/player/queue/QueueManager.kt @@ -11,8 +11,6 @@ import com.google.android.exoplayer2.source.MergingMediaSource import com.google.android.exoplayer2.source.ProgressiveMediaSource import com.google.android.exoplayer2.source.SingleSampleMediaSource import com.google.android.exoplayer2.source.hls.HlsMediaSource -import com.google.android.exoplayer2.upstream.DataSource -import com.google.android.exoplayer2.upstream.FileDataSource import kotlinx.coroutines.runBlocking import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.player.PlayerException @@ -224,7 +222,7 @@ class QueueManager( if (source.isDownload) { val downloadDao: DownloadDao = get() val fileUri = runBlocking { - downloadDao.getFileURI(source.id) + downloadDao.getMediaUri(source.id) } return prepareDownloadStreams(source, fileUri) } else { @@ -339,8 +337,7 @@ class QueueManager( @CheckResult private fun createDownloadVideoMediaSource(mediaSourceId: String, fileUri: String): MediaSource { - val dataSourceFactory: DataSource.Factory = FileDataSource.Factory() - val mediaSourceFactory = ProgressiveMediaSource.Factory(dataSourceFactory) + val mediaSourceFactory: ProgressiveMediaSource.Factory = get() val mediaItem = MediaItem.Builder() .setMediaId(mediaSourceId) diff --git a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt index c33fdcbcd..47f15d3a2 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/Constants.kt @@ -148,4 +148,6 @@ object Constants { // Misc const val PERCENT_MAX = 100 + const val DOWNLOAD_PATH = "/MediaCache/" + const val DOWNLOAD_THUMBNAIL_FILENAME = "thumbnail.jpg" } diff --git a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt index 5378cd1c5..180c19c23 100644 --- a/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt +++ b/app/src/main/java/org/jellyfin/mobile/utils/SystemUtils.kt @@ -17,6 +17,7 @@ import android.provider.Settings import android.provider.Settings.System.ACCELEROMETER_ROTATION import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.content.getSystemService +import com.google.android.exoplayer2.offline.DownloadService import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.suspendCancellableCoroutine import org.jellyfin.mobile.BuildConfig @@ -27,6 +28,7 @@ import org.jellyfin.mobile.data.dao.DownloadDao import org.jellyfin.mobile.data.entity.DownloadEntity import org.jellyfin.mobile.downloads.DownloadMethod import org.jellyfin.mobile.downloads.DownloadUtils +import org.jellyfin.mobile.downloads.JellyfinDownloadService import org.jellyfin.mobile.player.source.JellyfinMediaSource import org.jellyfin.mobile.settings.ExternalPlayerPackage import org.jellyfin.mobile.webapp.WebViewFragment @@ -129,9 +131,28 @@ suspend fun MainActivity.removeDownload(download: JellyfinMediaSource, force: Bo val downloadDao: DownloadDao = get() val downloadEntity: DownloadEntity = downloadDao.get(download.id) - val downloadDir = File(downloadEntity.fileURI).parentFile + val downloadDir = File(downloadEntity.downloadFolderUri) downloadDao.delete(download.id) downloadDir?.deleteRecursively() + + val contentId = download.itemId.toString() + // Remove media file + DownloadService.sendRemoveDownload( + this, + JellyfinDownloadService::class.java, + contentId, + false + ) + + // Remove subtitles + download!!.externalSubtitleStreams.forEach { + DownloadService.sendRemoveDownload( + this, + JellyfinDownloadService::class.java, + "${contentId}:${it.index}", + false + ) + } }