From 3509a0906472cda4ca1eb494046949fc2190bfbf Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Mon, 13 Apr 2026 16:31:57 +0200 Subject: [PATCH] Separate mobile downloads from TV playback --- .../bbara/purefin/tv/di/TvPlaybackModule.kt | 56 ++++++++++++++ .../di/TvNoOpMediaDownloadControllerTest.kt | 19 +++++ .../app/content/episode/EpisodeComponents.kt | 2 +- .../app/content/episode/EpisodeScreen.kt | 2 +- .../app/content/movie/MovieComponents.kt | 2 +- .../purefin/app/content/movie/MovieScreen.kt | 2 +- .../app/content/series/SeriesComponents.kt | 2 +- .../app/content/series/SeriesScreen.kt | 2 +- .../core/data/client/PlaybackCacheKeys.kt | 22 ++++++ .../core/data}/download/DownloadState.kt | 2 +- .../data/download/MediaDownloadController.kt | 17 +++++ .../core/data/client/PlaybackCacheKeysTest.kt | 43 +++++++++++ .../core/player/data/PlayerMediaRepository.kt | 24 ++++-- .../player/module/PlaybackNetworkModule.kt | 23 ++++++ .../core/player/module/VideoPlayerModule.kt | 9 ++- feature/download/build.gradle.kts | 1 + .../download/DownloadControllerModule.kt | 19 +++++ .../feature/download/DownloadModule.kt | 38 +++++++--- .../feature/download/MediaDownloadManager.kt | 74 ++++++++++++++----- .../feature/download/DownloadModuleTest.kt | 43 +++++++++++ feature/shared/build.gradle.kts | 1 - .../content/episode/EpisodeScreenViewModel.kt | 6 +- .../content/movie/MovieScreenViewModel.kt | 6 +- .../shared/content/series/SeriesViewModel.kt | 6 +- .../shared/download/DownloadsViewModel.kt | 4 +- .../feature/shared/home/AppViewModel.kt | 4 +- 26 files changed, 369 insertions(+), 60 deletions(-) create mode 100644 app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackModule.kt create mode 100644 app-tv/src/test/java/hu/bbara/purefin/tv/di/TvNoOpMediaDownloadControllerTest.kt create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeys.kt rename {feature/download/src/main/java/hu/bbara/purefin/feature => core/data/src/main/java/hu/bbara/purefin/core/data}/download/DownloadState.kt (84%) create mode 100644 core/data/src/main/java/hu/bbara/purefin/core/data/download/MediaDownloadController.kt create mode 100644 core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeysTest.kt create mode 100644 core/player/src/main/java/hu/bbara/purefin/core/player/module/PlaybackNetworkModule.kt create mode 100644 feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadControllerModule.kt create mode 100644 feature/download/src/test/java/hu/bbara/purefin/feature/download/DownloadModuleTest.kt diff --git a/app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackModule.kt b/app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackModule.kt new file mode 100644 index 00000000..b5269998 --- /dev/null +++ b/app-tv/src/main/java/hu/bbara/purefin/tv/di/TvPlaybackModule.kt @@ -0,0 +1,56 @@ +package hu.bbara.purefin.tv.di + +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.core.data.download.DownloadState +import hu.bbara.purefin.core.data.download.MediaDownloadController +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.jellyfin.sdk.model.UUID +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object TvPlaybackModule { + + @Provides + @Singleton + fun providePlaybackDataSourceFactory( + okHttpDataSourceFactory: OkHttpDataSource.Factory + ): DataSource.Factory { + return okHttpDataSourceFactory + } + + @Provides + @Singleton + fun provideMediaDownloadController(): MediaDownloadController { + return TvNoOpMediaDownloadController + } +} + +internal object TvNoOpMediaDownloadController : MediaDownloadController { + private val notDownloaded = MutableStateFlow(DownloadState.NotDownloaded) + + override fun observeActiveDownloads(): Flow> = flowOf(emptyMap()) + + override fun observeDownloadState(contentId: String) = notDownloaded + + override suspend fun downloadMovie(movieId: UUID) = Unit + + override suspend fun cancelDownload(movieId: UUID) = Unit + + override suspend fun downloadEpisode(episodeId: UUID) = Unit + + override suspend fun downloadEpisodes(episodeIds: List) = Unit + + override suspend fun cancelEpisodeDownload(episodeId: UUID) = Unit + + override suspend fun enableSmartDownload(seriesId: UUID) = Unit + + override suspend fun syncSmartDownloads() = Unit +} diff --git a/app-tv/src/test/java/hu/bbara/purefin/tv/di/TvNoOpMediaDownloadControllerTest.kt b/app-tv/src/test/java/hu/bbara/purefin/tv/di/TvNoOpMediaDownloadControllerTest.kt new file mode 100644 index 00000000..5415e5c0 --- /dev/null +++ b/app-tv/src/test/java/hu/bbara/purefin/tv/di/TvNoOpMediaDownloadControllerTest.kt @@ -0,0 +1,19 @@ +package hu.bbara.purefin.tv.di + +import hu.bbara.purefin.core.data.download.DownloadState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class TvNoOpMediaDownloadControllerTest { + + @Test + fun `tv no-op download controller always reports no downloads`() = runBlocking { + assertEquals(emptyMap(), TvNoOpMediaDownloadController.observeActiveDownloads().first()) + assertEquals( + DownloadState.NotDownloaded, + TvNoOpMediaDownloadController.observeDownloadState("movie-1").value + ) + } +} diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt index c31be505..acae6f3a 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeComponents.kt @@ -43,8 +43,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.model.Episode -import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.player.PlayerActivity internal sealed interface EpisodeTopBarShortcut { diff --git a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt index 6f5d87d5..1741a076 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/episode/EpisodeScreen.kt @@ -37,13 +37,13 @@ import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.LocalNavigationBackStack import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.Episode -import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.shared.content.episode.EpisodeScreenViewModel import hu.bbara.purefin.ui.theme.AppTheme import org.jellyfin.sdk.model.api.ImageType diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt index 1a3e26e7..75c69ad2 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieComponents.kt @@ -35,8 +35,8 @@ import hu.bbara.purefin.common.ui.components.GhostIconButton import hu.bbara.purefin.common.ui.components.MediaActionButton import hu.bbara.purefin.common.ui.components.MediaPlaybackSettings import hu.bbara.purefin.common.ui.components.MediaResumeButton +import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.model.Movie -import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.player.PlayerActivity @Composable diff --git a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt index 78858a49..330101d6 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/movie/MovieScreen.kt @@ -35,11 +35,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.MediaMetaChip import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.Movie -import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.shared.content.movie.MovieScreenViewModel import hu.bbara.purefin.ui.theme.AppTheme import org.jellyfin.sdk.model.api.ImageType diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt index 1daf1717..739a500c 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesComponents.kt @@ -65,6 +65,7 @@ import hu.bbara.purefin.common.ui.components.MediaProgressBar import hu.bbara.purefin.common.ui.components.MediaResumeButton import hu.bbara.purefin.common.ui.components.PurefinAsyncImage import hu.bbara.purefin.common.ui.components.WatchStateIndicator +import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.LocalNavigationManager @@ -73,7 +74,6 @@ import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Series -import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.player.PlayerActivity import org.jellyfin.sdk.model.api.ImageType diff --git a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt index a389ccc2..97d2c4c3 100644 --- a/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt +++ b/app/src/main/java/hu/bbara/purefin/app/content/series/SeriesScreen.kt @@ -33,13 +33,13 @@ import androidx.hilt.navigation.compose.hiltViewModel import hu.bbara.purefin.common.ui.MediaSynopsis import hu.bbara.purefin.common.ui.PurefinWaitingScreen import hu.bbara.purefin.common.ui.components.MediaHero +import hu.bbara.purefin.core.data.download.DownloadState import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.model.CastMember import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Series -import hu.bbara.purefin.feature.download.DownloadState import hu.bbara.purefin.feature.shared.content.series.SeriesViewModel import hu.bbara.purefin.ui.theme.AppTheme import org.jellyfin.sdk.model.api.ImageType diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeys.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeys.kt new file mode 100644 index 00000000..e0278c57 --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeys.kt @@ -0,0 +1,22 @@ +package hu.bbara.purefin.core.data.client + +import org.jellyfin.sdk.model.api.PlayMethod + +fun playbackCustomCacheKey( + mediaId: String, + playbackUrl: String, + playMethod: PlayMethod +): String? { + val normalizedUrl = playbackUrl.substringBefore('?').substringBefore('#').lowercase() + val isAdaptiveManifest = + normalizedUrl.endsWith(".m3u8") || + normalizedUrl.endsWith(".mpd") || + normalizedUrl.endsWith(".ism") || + normalizedUrl.contains(".ism/") + + return if (playMethod == PlayMethod.DIRECT_PLAY && !isAdaptiveManifest) { + mediaId + } else { + null + } +} diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadState.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/download/DownloadState.kt similarity index 84% rename from feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadState.kt rename to core/data/src/main/java/hu/bbara/purefin/core/data/download/DownloadState.kt index 5a75d532..b63be137 100644 --- a/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadState.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/download/DownloadState.kt @@ -1,4 +1,4 @@ -package hu.bbara.purefin.feature.download +package hu.bbara.purefin.core.data.download sealed class DownloadState { data object NotDownloaded : DownloadState() diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/download/MediaDownloadController.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/download/MediaDownloadController.kt new file mode 100644 index 00000000..c20289ab --- /dev/null +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/download/MediaDownloadController.kt @@ -0,0 +1,17 @@ +package hu.bbara.purefin.core.data.download + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.jellyfin.sdk.model.UUID + +interface MediaDownloadController { + fun observeActiveDownloads(): Flow> + fun observeDownloadState(contentId: String): StateFlow + suspend fun downloadMovie(movieId: UUID) + suspend fun cancelDownload(movieId: UUID) + suspend fun downloadEpisode(episodeId: UUID) + suspend fun downloadEpisodes(episodeIds: List) + suspend fun cancelEpisodeDownload(episodeId: UUID) + suspend fun enableSmartDownload(seriesId: UUID) + suspend fun syncSmartDownloads() +} diff --git a/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeysTest.kt b/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeysTest.kt new file mode 100644 index 00000000..68d828d9 --- /dev/null +++ b/core/data/src/test/java/hu/bbara/purefin/core/data/client/PlaybackCacheKeysTest.kt @@ -0,0 +1,43 @@ +package hu.bbara.purefin.core.data.client + +import org.jellyfin.sdk.model.api.PlayMethod +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlaybackCacheKeysTest { + + @Test + fun `progressive direct play gets stable cache key`() { + assertEquals( + "movie-1", + playbackCustomCacheKey( + mediaId = "movie-1", + playbackUrl = "https://example.com/Videos/movie-1.mp4", + playMethod = PlayMethod.DIRECT_PLAY + ) + ) + } + + @Test + fun `adaptive direct stream skips stable cache key`() { + assertNull( + playbackCustomCacheKey( + mediaId = "episode-1", + playbackUrl = "https://example.com/Videos/episode-1/master.m3u8", + playMethod = PlayMethod.DIRECT_STREAM + ) + ) + } + + @Test + fun `manifest-looking direct play still skips stable cache key`() { + assertNull( + playbackCustomCacheKey( + mediaId = "episode-2", + playbackUrl = "https://example.com/Videos/episode-2/master.m3u8", + playMethod = PlayMethod.DIRECT_PLAY + ) + ) + } +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt index dd5e565a..96814ca6 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt @@ -1,12 +1,16 @@ package hu.bbara.purefin.core.player.data import android.util.Log +import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi import dagger.hilt.android.scopes.ViewModelScoped import hu.bbara.purefin.core.data.client.JellyfinApiClient +import hu.bbara.purefin.core.data.client.PlaybackDecision import hu.bbara.purefin.core.data.client.PlaybackReportContext +import hu.bbara.purefin.core.data.client.playbackCustomCacheKey import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.session.UserSessionRepository import kotlinx.coroutines.Dispatchers @@ -35,7 +39,7 @@ class PlayerMediaRepository @Inject constructor( val mediaItem = createMediaItem( mediaId = mediaId.toString(), - playbackUrl = playbackDecision.url, + playbackDecision = playbackDecision, title = baseItem?.name ?: playbackDecision.mediaSource.name ?: return@withContext null, subtitle = seasonEpisodeLabel(baseItem), artworkUrl = artworkUrl, @@ -59,7 +63,7 @@ class PlayerMediaRepository @Inject constructor( val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) createMediaItem( mediaId = stringId, - playbackUrl = playbackDecision.url, + playbackDecision = playbackDecision, title = episode.name ?: playbackDecision.mediaSource.name ?: return@mapNotNull null, subtitle = seasonEpisodeLabel(episode), artworkUrl = artworkUrl, @@ -72,9 +76,10 @@ class PlayerMediaRepository @Inject constructor( } } + @OptIn(UnstableApi::class) private fun createMediaItem( mediaId: String, - playbackUrl: String, + playbackDecision: PlaybackDecision, title: String, subtitle: String?, artworkUrl: String, @@ -85,12 +90,19 @@ class PlayerMediaRepository @Inject constructor( .setSubtitle(subtitle) .setArtworkUri(artworkUrl.toUri()) .build() - return MediaItem.Builder() - .setUri(playbackUrl.toUri()) + val builder = MediaItem.Builder() + .setUri(playbackDecision.url.toUri()) .setMediaId(mediaId) .setMediaMetadata(metadata) .setTag(playbackReportContext) - .build() + + playbackCustomCacheKey( + mediaId = mediaId, + playbackUrl = playbackDecision.url, + playMethod = playbackDecision.reportContext.playMethod + )?.let(builder::setCustomCacheKey) + + return builder.build() } private fun calculateResumePosition( diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/module/PlaybackNetworkModule.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/module/PlaybackNetworkModule.kt new file mode 100644 index 00000000..7627a3c9 --- /dev/null +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/module/PlaybackNetworkModule.kt @@ -0,0 +1,23 @@ +package hu.bbara.purefin.core.player.module + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.okhttp.OkHttpDataSource +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import javax.inject.Singleton + +@OptIn(UnstableApi::class) +@Module +@InstallIn(SingletonComponent::class) +object PlaybackNetworkModule { + + @Provides + @Singleton + fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory { + return OkHttpDataSource.Factory(okHttpClient) + } +} diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt index e9000e0e..b3052d24 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/module/VideoPlayerModule.kt @@ -6,7 +6,7 @@ import androidx.media3.common.AudioAttributes import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.DataSource import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer @@ -26,7 +26,10 @@ object VideoPlayerModule { @OptIn(UnstableApi::class) @Provides @ViewModelScoped - fun provideVideoPlayer(application: Application, cacheDataSourceFactory: CacheDataSource.Factory): Player { + fun provideVideoPlayer( + application: Application, + playbackDataSourceFactory: DataSource.Factory + ): Player { val trackSelector = DefaultTrackSelector(application) val audioAttributes = AudioAttributes.Builder() @@ -53,7 +56,7 @@ object VideoPlayerModule { .setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) .setEnableDecoderFallback(true) - val mediaSourceFactory = DefaultMediaSourceFactory(cacheDataSourceFactory) + val mediaSourceFactory = DefaultMediaSourceFactory(playbackDataSourceFactory) return ExoPlayer.Builder(application, renderersFactory) .setMediaSourceFactory(mediaSourceFactory) diff --git a/feature/download/build.gradle.kts b/feature/download/build.gradle.kts index f64d6149..336e149d 100644 --- a/feature/download/build.gradle.kts +++ b/feature/download/build.gradle.kts @@ -36,4 +36,5 @@ dependencies { implementation(libs.media3.datasource.okhttp) implementation(libs.okhttp) implementation(libs.jellyfin.core) + testImplementation(libs.junit) } diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadControllerModule.kt b/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadControllerModule.kt new file mode 100644 index 00000000..40cf40a2 --- /dev/null +++ b/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadControllerModule.kt @@ -0,0 +1,19 @@ +package hu.bbara.purefin.feature.download + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import hu.bbara.purefin.core.data.download.MediaDownloadController +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DownloadControllerModule { + + @Binds + @Singleton + abstract fun bindMediaDownloadController( + impl: MediaDownloadManager + ): MediaDownloadController +} diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadModule.kt b/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadModule.kt index 42073a38..4446b7ca 100644 --- a/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadModule.kt +++ b/feature/download/src/main/java/hu/bbara/purefin/feature/download/DownloadModule.kt @@ -4,7 +4,9 @@ import android.content.Context import androidx.annotation.OptIn import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheEvictor import androidx.media3.datasource.cache.NoOpCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource @@ -15,11 +17,25 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient import java.io.File import java.util.concurrent.Executors import javax.inject.Singleton +@OptIn(UnstableApi::class) +internal object DownloadModuleFactories { + fun createDownloadCacheEvictor(): CacheEvictor { + return NoOpCacheEvictor() + } + + fun newPhonePlaybackDataSourceFactory( + upstreamDataSourceFactory: DataSource.Factory + ): CacheDataSource.Factory { + return CacheDataSource.Factory() + .setUpstreamDataSourceFactory(upstreamDataSourceFactory) + .setCacheWriteDataSinkFactory(null) + } +} + @OptIn(UnstableApi::class) @Module @InstallIn(SingletonComponent::class) @@ -31,13 +47,11 @@ object DownloadModule { @Singleton fun provideDownloadCache(@ApplicationContext context: Context): SimpleCache { val downloadDir = File(context.getExternalFilesDir(null), "downloads") - return SimpleCache(downloadDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(context)) - } - - @Provides - @Singleton - fun provideOkHttpDataSourceFactory(okHttpClient: OkHttpClient): OkHttpDataSource.Factory { - return OkHttpDataSource.Factory(okHttpClient) + return SimpleCache( + downloadDir, + DownloadModuleFactories.createDownloadCacheEvictor(), + StandaloneDatabaseProvider(context) + ) } @Provides @@ -64,12 +78,12 @@ object DownloadModule { @Provides @Singleton - fun provideCacheDataSourceFactory( + fun providePlaybackDataSourceFactory( cache: SimpleCache, okHttpDataSourceFactory: OkHttpDataSource.Factory - ): CacheDataSource.Factory { - return CacheDataSource.Factory() + ): DataSource.Factory { + return DownloadModuleFactories + .newPhonePlaybackDataSourceFactory(okHttpDataSourceFactory) .setCache(cache) - .setUpstreamDataSourceFactory(okHttpDataSourceFactory) } } diff --git a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt index 76014391..0a4320a6 100644 --- a/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt +++ b/feature/download/src/main/java/hu/bbara/purefin/feature/download/MediaDownloadManager.kt @@ -10,8 +10,11 @@ import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadRequest import dagger.hilt.android.qualifiers.ApplicationContext import hu.bbara.purefin.core.data.InMemoryMediaRepository +import hu.bbara.purefin.core.data.client.playbackCustomCacheKey import hu.bbara.purefin.core.data.client.JellyfinApiClient import hu.bbara.purefin.core.data.image.JellyfinImageHelper +import hu.bbara.purefin.core.data.download.DownloadState +import hu.bbara.purefin.core.data.download.MediaDownloadController import hu.bbara.purefin.core.data.room.dao.MovieDao import hu.bbara.purefin.core.data.room.dao.SmartDownloadDao import hu.bbara.purefin.core.data.room.entity.SmartDownloadEntity @@ -33,9 +36,11 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jellyfin.sdk.model.UUID import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType -import java.util.UUID +import org.jellyfin.sdk.model.api.MediaSourceInfo +import org.jellyfin.sdk.model.api.PlayMethod import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton @@ -51,7 +56,7 @@ class MediaDownloadManager @Inject constructor( private val smartDownloadDao: SmartDownloadDao, private val userSessionRepository: UserSessionRepository, private val inMemoryMediaRepository: InMemoryMediaRepository, -) { +) : MediaDownloadController { private val stateFlows = ConcurrentHashMap>() @@ -87,7 +92,7 @@ class MediaDownloadManager @Inject constructor( * Uses the download index directly so it always reflects the true download state, * regardless of listener callback timing. */ - fun observeActiveDownloads(): Flow> = flow { + override fun observeActiveDownloads(): Flow> = flow { while (true) { try { val result = buildMap { @@ -112,7 +117,7 @@ class MediaDownloadManager @Inject constructor( } }.flowOn(Dispatchers.IO).distinctUntilChanged() - fun observeDownloadState(contentId: String): StateFlow { + override fun observeDownloadState(contentId: String): StateFlow { val flow = getOrCreateStateFlow(contentId) // Initialize from current download index val download = downloadManager.downloadIndex.getDownload(contentId) @@ -124,7 +129,7 @@ class MediaDownloadManager @Inject constructor( return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED } - suspend fun downloadMovie(movieId: UUID) { + override suspend fun downloadMovie(movieId: UUID) { withContext(Dispatchers.IO) { try { val sources = jellyfinApiClient.getMediaSources(movieId) @@ -132,6 +137,7 @@ class MediaDownloadManager @Inject constructor( Log.e(TAG, "No media sources for $movieId") return@withContext } + val shouldTranscode = source.shouldUseTranscodingUrl() val url = jellyfinApiClient.getMediaPlaybackUrl(movieId, source) ?: run { Log.e(TAG, "No playback URL for $movieId") @@ -168,9 +174,12 @@ class MediaDownloadManager @Inject constructor( offlineDataSource.saveMovies(listOf(movie)) Log.d(TAG, "Starting download for '${movie.title}' from: $url") - val request = DownloadRequest.Builder(movieId.toString(), url.toUri()) - .setData(movie.title.toByteArray(Charsets.UTF_8)) - .build() + val request = buildDownloadRequest( + mediaId = movieId, + playbackUrl = url, + title = movie.title, + isDirectPlay = !shouldTranscode + ) PurefinDownloadService.sendAddDownload(context, request) Log.d(TAG, "Download request sent for $movieId") } catch (e: Exception) { @@ -180,7 +189,7 @@ class MediaDownloadManager @Inject constructor( } } - suspend fun cancelDownload(movieId: UUID) { + override suspend fun cancelDownload(movieId: UUID) { withContext(Dispatchers.IO) { PurefinDownloadService.sendRemoveDownload(context, movieId.toString()) try { @@ -191,7 +200,7 @@ class MediaDownloadManager @Inject constructor( } } - suspend fun downloadEpisode(episodeId: UUID) { + override suspend fun downloadEpisode(episodeId: UUID) { withContext(Dispatchers.IO) { try { val serverUrl = userSessionRepository.serverUrl.first().trim() @@ -200,6 +209,7 @@ class MediaDownloadManager @Inject constructor( Log.e(TAG, "No media sources for episode $episodeId") return@withContext } + val shouldTranscode = source.shouldUseTranscodingUrl() val url = jellyfinApiClient.getMediaPlaybackUrl(episodeId, source) ?: run { Log.e(TAG, "No playback URL for episode $episodeId") @@ -232,9 +242,12 @@ class MediaDownloadManager @Inject constructor( offlineDataSource.saveEpisode(episode) Log.d(TAG, "Starting download for episode '${episode.title}' from: $url") - val request = DownloadRequest.Builder(episodeId.toString(), url.toUri()) - .setData(episode.title.toByteArray(Charsets.UTF_8)) - .build() + val request = buildDownloadRequest( + mediaId = episodeId, + playbackUrl = url, + title = episode.title, + isDirectPlay = !shouldTranscode + ) PurefinDownloadService.sendAddDownload(context, request) Log.d(TAG, "Download request sent for episode $episodeId") } catch (e: Exception) { @@ -244,7 +257,7 @@ class MediaDownloadManager @Inject constructor( } } - suspend fun downloadEpisodes(episodeIds: List) { + override suspend fun downloadEpisodes(episodeIds: List) { coroutineScope { for (episodeId in episodeIds) { launch { downloadEpisode(episodeId) } @@ -252,7 +265,7 @@ class MediaDownloadManager @Inject constructor( } } - suspend fun cancelEpisodeDownload(episodeId: UUID) { + override suspend fun cancelEpisodeDownload(episodeId: UUID) { withContext(Dispatchers.IO) { PurefinDownloadService.sendRemoveDownload(context, episodeId.toString()) try { @@ -265,7 +278,7 @@ class MediaDownloadManager @Inject constructor( // ── Smart Download ────────────────────────────────────────────────── - suspend fun enableSmartDownload(seriesId: UUID) { + override suspend fun enableSmartDownload(seriesId: UUID) { smartDownloadDao.insert(SmartDownloadEntity(seriesId)) syncSmartDownloadsForSeries(seriesId) } @@ -276,7 +289,7 @@ class MediaDownloadManager @Inject constructor( fun isSmartDownloadEnabled(seriesId: UUID): Flow = smartDownloadDao.observe(seriesId) - suspend fun syncSmartDownloads() { + override suspend fun syncSmartDownloads() { withContext(Dispatchers.IO) { val enabled = smartDownloadDao.getAll() for (entry in enabled) { @@ -337,6 +350,26 @@ class MediaDownloadManager @Inject constructor( return stateFlows.getOrPut(contentId) { MutableStateFlow(DownloadState.NotDownloaded) } } + private fun buildDownloadRequest( + mediaId: UUID, + playbackUrl: String, + title: String, + isDirectPlay: Boolean + ): DownloadRequest { + val builder = DownloadRequest.Builder(mediaId.toString(), playbackUrl.toUri()) + .setData(title.toByteArray(Charsets.UTF_8)) + + if (isDirectPlay) { + playbackCustomCacheKey( + mediaId = mediaId.toString(), + playbackUrl = playbackUrl, + playMethod = PlayMethod.DIRECT_PLAY + )?.let(builder::setCustomCacheKey) + } + + return builder.build() + } + private fun Download.toDownloadState(): DownloadState = when (state) { Download.STATE_COMPLETED -> DownloadState.Downloaded Download.STATE_DOWNLOADING -> DownloadState.Downloading(percentDownloaded) @@ -380,7 +413,7 @@ class MediaDownloadManager @Inject constructor( private fun BaseItemDto.toSeries(serverUrl: String): Series { return Series( id = id, - libraryId = parentId ?: UUID.randomUUID(), + libraryId = parentId ?: java.util.UUID.randomUUID(), name = name ?: "Unknown", synopsis = overview ?: "No synopsis available", year = productionYear?.toString() ?: premiereDate?.year?.toString().orEmpty(), @@ -412,3 +445,8 @@ class MediaDownloadManager @Inject constructor( private const val SMART_DOWNLOAD_COUNT = 5 } } + +private fun MediaSourceInfo.shouldUseTranscodingUrl(): Boolean { + return supportsTranscoding == true && + (supportsDirectPlay == false || transcodingUrl != null) +} diff --git a/feature/download/src/test/java/hu/bbara/purefin/feature/download/DownloadModuleTest.kt b/feature/download/src/test/java/hu/bbara/purefin/feature/download/DownloadModuleTest.kt new file mode 100644 index 00000000..c602f6be --- /dev/null +++ b/feature/download/src/test/java/hu/bbara/purefin/feature/download/DownloadModuleTest.kt @@ -0,0 +1,43 @@ +package hu.bbara.purefin.feature.download + +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.NoOpCacheEvictor +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class DownloadModuleTest { + + @Test + fun `download cache keeps no-op evictor`() { + assertTrue(DownloadModuleFactories.createDownloadCacheEvictor() is NoOpCacheEvictor) + } + + @Test + fun `phone playback data source reads download cache and writes upstream only`() { + val upstreamFactory = FakeDataSourceFactory() + val factory = DownloadModuleFactories.newPhonePlaybackDataSourceFactory(upstreamFactory) + + assertTrue(readPrivateBoolean(factory, "cacheIsReadOnly")) + assertNull(readPrivateField(factory, "cacheWriteDataSinkFactory")) + assertSame(upstreamFactory, readPrivateField(factory, "upstreamDataSourceFactory")) + } + + private fun readPrivateBoolean(instance: CacheDataSource.Factory, fieldName: String): Boolean { + return readPrivateField(instance, fieldName) as Boolean + } + + private fun readPrivateField(instance: CacheDataSource.Factory, fieldName: String): Any? { + val field = instance.javaClass.getDeclaredField(fieldName) + field.isAccessible = true + return field.get(instance) + } +} + +private class FakeDataSourceFactory : DataSource.Factory { + override fun createDataSource(): DataSource { + throw UnsupportedOperationException("Unused in unit tests") + } +} diff --git a/feature/shared/build.gradle.kts b/feature/shared/build.gradle.kts index 32dc3903..2e833269 100644 --- a/feature/shared/build.gradle.kts +++ b/feature/shared/build.gradle.kts @@ -30,7 +30,6 @@ kotlin { dependencies { implementation(project(":core:model")) implementation(project(":core:data")) - implementation(project(":feature:download")) implementation(libs.hilt) ksp(libs.hilt.compiler) implementation(libs.jellyfin.core) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt index 390e626a..9aceab9b 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/episode/EpisodeScreenViewModel.kt @@ -7,9 +7,9 @@ import hu.bbara.purefin.core.data.AppContentRepository import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.SeriesDto +import hu.bbara.purefin.core.data.download.DownloadState +import hu.bbara.purefin.core.data.download.MediaDownloadController import hu.bbara.purefin.core.model.Episode -import hu.bbara.purefin.feature.download.DownloadState -import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -24,7 +24,7 @@ import javax.inject.Inject class EpisodeScreenViewModel @Inject constructor( private val appContentRepository: AppContentRepository, private val navigationManager: NavigationManager, - private val mediaDownloadManager: MediaDownloadManager, + private val mediaDownloadManager: MediaDownloadController, ): ViewModel() { private val _episodeId = MutableStateFlow(null) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/movie/MovieScreenViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/movie/MovieScreenViewModel.kt index 5714a8b3..0d2883ea 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/movie/MovieScreenViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/movie/MovieScreenViewModel.kt @@ -6,9 +6,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.MediaRepository import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.core.data.download.DownloadState +import hu.bbara.purefin.core.data.download.MediaDownloadController import hu.bbara.purefin.core.model.Movie -import hu.bbara.purefin.feature.download.DownloadState -import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -23,7 +23,7 @@ import javax.inject.Inject class MovieScreenViewModel @Inject constructor( private val mediaRepository: MediaRepository, private val navigationManager: NavigationManager, - private val mediaDownloadManager: MediaDownloadManager + private val mediaDownloadManager: MediaDownloadController ): ViewModel() { private val _movieId = MutableStateFlow(null) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt index 5f2b0c88..7bad3e65 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/content/series/SeriesViewModel.kt @@ -7,10 +7,10 @@ import hu.bbara.purefin.core.data.MediaRepository import hu.bbara.purefin.core.data.navigation.EpisodeDto import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route +import hu.bbara.purefin.core.data.download.DownloadState +import hu.bbara.purefin.core.data.download.MediaDownloadController import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Series -import hu.bbara.purefin.feature.download.DownloadState -import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -27,7 +27,7 @@ import javax.inject.Inject class SeriesViewModel @Inject constructor( private val mediaRepository: MediaRepository, private val navigationManager: NavigationManager, - private val mediaDownloadManager: MediaDownloadManager, + private val mediaDownloadManager: MediaDownloadController, ) : ViewModel() { private val _seriesId = MutableStateFlow(null) diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt index 9277c427..61e369f8 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/download/DownloadsViewModel.kt @@ -4,12 +4,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.OfflineMediaRepository +import hu.bbara.purefin.core.data.download.MediaDownloadController import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.navigation.MovieDto import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.SeriesDto -import hu.bbara.purefin.feature.download.MediaDownloadManager import hu.bbara.purefin.feature.shared.home.PosterItem import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine @@ -24,7 +24,7 @@ import javax.inject.Inject class DownloadsViewModel @Inject constructor( private val offlineMediaRepository: OfflineMediaRepository, private val navigationManager: NavigationManager, - private val downloadManager: MediaDownloadManager + private val downloadManager: MediaDownloadController ) : ViewModel() { fun onMovieSelected(movieId: UUID) { diff --git a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt index a89a900b..ad63f53b 100644 --- a/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt +++ b/feature/shared/src/main/java/hu/bbara/purefin/feature/shared/home/AppViewModel.kt @@ -12,8 +12,8 @@ import hu.bbara.purefin.core.data.navigation.NavigationManager import hu.bbara.purefin.core.data.navigation.Route import hu.bbara.purefin.core.data.navigation.SeriesDto import hu.bbara.purefin.core.data.session.UserSessionRepository +import hu.bbara.purefin.core.data.download.MediaDownloadController import hu.bbara.purefin.core.model.Media -import hu.bbara.purefin.feature.download.MediaDownloadManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -33,7 +33,7 @@ class AppViewModel @Inject constructor( private val userSessionRepository: UserSessionRepository, private val navigationManager: NavigationManager, private val refreshHomeDataUseCase: RefreshHomeDataUseCase, - private val mediaDownloadManager: MediaDownloadManager, + private val mediaDownloadManager: MediaDownloadController, ) : ViewModel() { private val _isRefreshing = MutableStateFlow(false)