Skip to content

Commit

Permalink
Move media and subtitle download to ExoPlayer Download Service
Browse files Browse the repository at this point in the history
  • Loading branch information
Emily Ehlert committed Jun 24, 2024
1 parent fa9937c commit d3b7ae5
Show file tree
Hide file tree
Showing 15 changed files with 432 additions and 227 deletions.
20 changes: 13 additions & 7 deletions app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "7557b2c520ecc7e54020a91aba6386b5",
"identityHash": "4366575e1609ecf3e2c44abd106d51d6",
"entities": [
{
"tableName": "Server",
Expand Down Expand Up @@ -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",
Expand All @@ -130,8 +130,8 @@
"notNull": true
},
{
"fieldPath": "fileURI",
"columnName": "file_uri",
"fieldPath": "mediaUri",
"columnName": "media_uri",
"affinity": "TEXT",
"notNull": true
},
Expand All @@ -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": {
Expand All @@ -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')"
]
}
}
13 changes: 13 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@
android:required="false"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />

<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
Expand Down Expand Up @@ -81,6 +83,17 @@
</intent-filter>
</service>

<service android:name="org.jellyfin.mobile.downloads.JellyfinDownloadService"
android:exported="false"
android:foregroundServiceType="dataSync">
<!-- This is needed for Scheduler -->
<intent-filter>
<action android:name="androidx.media3.exoplayer.downloadService.action.RESTART"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</service>


<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true"
Expand Down
57 changes: 51 additions & 6 deletions app/src/main/java/org/jellyfin/mobile/app/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package org.jellyfin.mobile.app
import org.jellyfin.mobile.downloads.DownloadsViewModel
import android.content.Context
import coil.ImageLoader
import com.google.android.exoplayer2.database.DatabaseProvider
import com.google.android.exoplayer2.database.StandaloneDatabaseProvider
import com.google.android.exoplayer2.ext.cronet.CronetDataSource
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory
import com.google.android.exoplayer2.extractor.ts.TsExtractor
Expand All @@ -11,9 +13,12 @@ import com.google.android.exoplayer2.source.MediaSource
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.DefaultDataSource
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource
import com.google.android.exoplayer2.upstream.cache.Cache
import com.google.android.exoplayer2.upstream.cache.CacheDataSource
import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor
import com.google.android.exoplayer2.upstream.cache.SimpleCache
import com.google.android.exoplayer2.util.Util
import kotlinx.coroutines.channels.Channel
import okhttp3.OkHttpClient
Expand All @@ -35,11 +40,13 @@ import org.jellyfin.mobile.utils.isLowRamDevice
import org.jellyfin.mobile.webapp.RemoteVolumeProvider
import org.jellyfin.mobile.webapp.WebViewFragment
import org.jellyfin.mobile.webapp.WebappFunctionChannel
import org.jellyfin.sdk.model.serializer.toUUID
import org.koin.android.ext.koin.androidApplication
import org.koin.androidx.fragment.dsl.fragment
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.qualifier.named
import org.koin.dsl.module
import java.io.File
import java.util.concurrent.Executors

const val PLAYER_EVENT_CHANNEL = "PlayerEventChannel"
Expand Down Expand Up @@ -81,7 +88,20 @@ val applicationModule = module {
single { QualityOptionsProvider() }

// ExoPlayer factories
single<DataSource.Factory> {
single <DatabaseProvider>{
val dbProvider = StandaloneDatabaseProvider(get<Context>())
dbProvider
}
single <Cache> {
val downloadPath = File(get<Context>().filesDir, Constants.DOWNLOAD_PATH)
if (!downloadPath.exists()) {
downloadPath.mkdirs()
}
val cache = SimpleCache(downloadPath, NoOpCacheEvictor(), get())
cache
}

single<DefaultDataSource.Factory> {
val context: Context = get()

val provider = CronetProvider.getAllProviders(context).firstOrNull { provider: CronetProvider ->
Expand All @@ -105,7 +125,32 @@ val applicationModule = module {
}

DefaultDataSource.Factory(context, baseDataSourceFactory)

}

single<CacheDataSource.Factory> {
// Create a read-only cache data source factory using the download cache.
CacheDataSource.Factory()
.setCache(get())
.setUpstreamDataSourceFactory(get<DefaultDataSource.Factory>())
.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<MediaSource.Factory> {
val context: Context = get()
val extractorsFactory = DefaultExtractorsFactory().apply {
Expand All @@ -117,11 +162,11 @@ val applicationModule = module {
},
)
}
DefaultMediaSourceFactory(get<DataSource.Factory>(), extractorsFactory)
DefaultMediaSourceFactory(get<CacheDataSource.Factory>(), extractorsFactory)
}
single { ProgressiveMediaSource.Factory(get()) }
single { HlsMediaSource.Factory(get<DataSource.Factory>()) }
single { SingleSampleMediaSource.Factory(get()) }
single { ProgressiveMediaSource.Factory(get<CacheDataSource.Factory>()) }
single { HlsMediaSource.Factory(get<CacheDataSource.Factory>()) }
single { SingleSampleMediaSource.Factory(get<CacheDataSource.Factory>()) }

// Media components
single { LibraryBrowser(get(), get()) }
Expand Down
8 changes: 4 additions & 4 deletions app/src/main/java/org/jellyfin/mobile/data/dao/DownloadDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

This file was deleted.

80 changes: 80 additions & 0 deletions app/src/main/java/org/jellyfin/mobile/downloads/DownloadService.kt
Original file line number Diff line number Diff line change
@@ -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<Download>, 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)
}
}
}
Loading

0 comments on commit d3b7ae5

Please sign in to comment.