Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add in app download functionality #1404

Open
wants to merge 36 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
53f5ec3
Add initial code for client internal downloads
May 27, 2024
bfd25c9
Add download to internal storage
May 27, 2024
7258688
Extend ExoPlayer to be able to play downloads
May 28, 2024
114c2de
Store Download Information separately
Jun 3, 2024
b133929
Working Downloads Fragment
Jun 3, 2024
6486fbe
Add openDownloads to NativeInterface
Jun 4, 2024
b2978eb
Add ability to remove downloads
Jun 4, 2024
56179f9
Improve Download Method Check
Jun 4, 2024
b25b6b2
Add detail to DownloadItem
Jun 4, 2024
109ec0d
Add option in settings to choose between external and internal download
Jun 4, 2024
a039e9f
Update layout dimensions
Jun 4, 2024
bff1b17
Fix order of deletion
Jun 4, 2024
ce17f38
Improve download speed
Jun 6, 2024
874b639
Improve exception handling for missing files
Jun 6, 2024
fa9937c
Improve layout and localization
Jun 6, 2024
d3b7ae5
Move media and subtitle download to ExoPlayer Download Service
Jun 24, 2024
ed904f2
Resolve detekt errors
neBM Jun 30, 2024
db53c86
Abstract JellyfinMediaSource
neBM Jun 30, 2024
3c85198
Rename KILOBYTE const to better communicate it's function
neBM Jul 2, 2024
5bdee10
Add support for selecting audio and encoded subtitle stream
neBM Jul 6, 2024
64ca273
Flatten DownloadItem & DownloadEntity into LocalJellyfinMediaSource
neBM Jul 6, 2024
8b9b8d9
Resolve detekt issues
neBM Jul 7, 2024
51d0ea2
Inline `prepareDownloadStreams` in QueueManager
neBM Jul 8, 2024
051de01
Merge pull request #1 from neBM/download
7ritn Jul 8, 2024
599851f
Merge branch 'master' into download
7ritn Jul 8, 2024
6b0817b
Fix build issue
Jul 8, 2024
b421059
Fix some more linting
Jul 8, 2024
57a112e
Switch to view binding
Jul 9, 2024
f0feb33
Remove obsolete files
Jul 9, 2024
068845f
Adjust layout
Jul 9, 2024
97dc624
Finally fix detekt
Jul 16, 2024
428bec3
Fix discard of some parallel downloads
Aug 18, 2024
7ce600c
Fix source error on HLS content
Aug 20, 2024
6f942e0
Move byte size conversion to separate file
Aug 22, 2024
6db98b7
Improve download notifications
Aug 22, 2024
48c9eb4
Fix detekt
Aug 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.detekt)
alias(libs.plugins.android.junit5)
Expand Down Expand Up @@ -108,6 +109,7 @@ dependencies {

// Kotlin
implementation(libs.bundles.coroutines)
implementation(libs.kotlin.serialization.json)

// Core
implementation(libs.bundles.koin)
Expand Down Expand Up @@ -141,6 +143,7 @@ dependencies {
}
}
implementation(libs.okhttp)
implementation(libs.okio)
implementation(libs.coil)
implementation(libs.cronet.embedded)

Expand All @@ -157,6 +160,7 @@ dependencies {

// Room
implementation(libs.bundles.androidx.room)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)

// Monitoring
Expand Down
8 changes: 4 additions & 4 deletions app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/2.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
Expand Down Expand Up @@ -82,10 +82,10 @@
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
],
"autoGenerate": true
]
},
"indices": [
{
Expand Down
159 changes: 159 additions & 0 deletions app/schemas/org.jellyfin.mobile.data.JellyfinDatabase/3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "26b179bda28d76008389cab4ce8cb631",
"entities": [
{
"tableName": "Server",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `hostname` TEXT NOT NULL, `last_used_timestamp` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "hostname",
"columnName": "hostname",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "lastUsedTimestamp",
"columnName": "last_used_timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_Server_hostname",
"unique": true,
"columnNames": [
"hostname"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Server_hostname` ON `${TABLE_NAME}` (`hostname`)"
}
],
"foreignKeys": []
},
{
"tableName": "User",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `server_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `access_token` TEXT, `last_login_timestamp` INTEGER NOT NULL, FOREIGN KEY(`server_id`) REFERENCES `Server`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serverId",
"columnName": "server_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "userId",
"columnName": "user_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accessToken",
"columnName": "access_token",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "lastLoginTimestamp",
"columnName": "last_login_timestamp",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_User_server_id_user_id",
"unique": true,
"columnNames": [
"server_id",
"user_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_User_server_id_user_id` ON `${TABLE_NAME}` (`server_id`, `user_id`)"
}
],
"foreignKeys": [
{
"table": "Server",
"onDelete": "NO ACTION",
"onUpdate": "NO ACTION",
"columns": [
"server_id"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Download",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`item_id` TEXT NOT NULL, `media_source` TEXT NOT NULL, PRIMARY KEY(`item_id`))",
"fields": [
{
"fieldPath": "itemId",
"columnName": "item_id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "mediaSource",
"columnName": "media_source",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"item_id"
]
},
"indices": [
{
"name": "index_Download_item_id",
"unique": true,
"columnNames": [
"item_id"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Download_item_id` ON `${TABLE_NAME}` (`item_id`)"
}
],
"foreignKeys": []
}
],
"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, '26b179bda28d76008389cab4ce8cb631')"
]
}
}
15 changes: 15 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 All @@ -26,6 +28,8 @@
android:maxSdkVersion="28"
tools:ignore="ScopedStorage" />

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
Expand Down Expand Up @@ -84,6 +88,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
4 changes: 4 additions & 0 deletions app/src/main/assets/native/nativeshell.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ window.NativeShell = {
window.NativeInterface.openClientSettings();
},

openDownloads() {
window.NativeInterface.openDownloads();
},

selectServer() {
window.NativeInterface.openServerSelection();
},
Expand Down
59 changes: 53 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 @@ -2,6 +2,8 @@ package org.jellyfin.mobile.app

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 @@ -10,16 +12,20 @@ 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
import org.chromium.net.CronetEngine
import org.chromium.net.CronetProvider
import org.jellyfin.mobile.MainViewModel
import org.jellyfin.mobile.bridge.NativePlayer
import org.jellyfin.mobile.downloads.DownloadsViewModel
import org.jellyfin.mobile.events.ActivityEventHandler
import org.jellyfin.mobile.player.audio.car.LibraryBrowser
import org.jellyfin.mobile.player.deviceprofile.DeviceProfileBuilder
Expand All @@ -34,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 All @@ -65,6 +73,7 @@ val applicationModule = module {

// ViewModels
viewModel { MainViewModel(get(), get()) }
viewModel { DownloadsViewModel() }

// Fragments
fragment { WebViewFragment() }
Expand All @@ -79,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 @@ -104,6 +126,31 @@ 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)
.setCacheWriteDataSinkFactory(null)
.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[1]?.value ?: subtitleResult.groups[2]?.value}"
}

item
}
}

single<MediaSource.Factory> {
val context: Context = get()
val extractorsFactory = DefaultExtractorsFactory().apply {
Expand All @@ -115,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
Loading