Separate mobile downloads from TV playback

This commit is contained in:
2026-04-13 16:31:57 +02:00
parent 9a130e9738
commit 3509a09064
26 changed files with 369 additions and 60 deletions

View File

@@ -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
}
}

View File

@@ -0,0 +1,8 @@
package hu.bbara.purefin.core.data.download
sealed class DownloadState {
data object NotDownloaded : DownloadState()
data class Downloading(val progressPercent: Float) : DownloadState()
data object Downloaded : DownloadState()
data object Failed : DownloadState()
}

View File

@@ -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<Map<String, Float>>
fun observeDownloadState(contentId: String): StateFlow<DownloadState>
suspend fun downloadMovie(movieId: UUID)
suspend fun cancelDownload(movieId: UUID)
suspend fun downloadEpisode(episodeId: UUID)
suspend fun downloadEpisodes(episodeIds: List<UUID>)
suspend fun cancelEpisodeDownload(episodeId: UUID)
suspend fun enableSmartDownload(seriesId: UUID)
suspend fun syncSmartDownloads()
}

View File

@@ -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
)
)
}
}

View File

@@ -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(

View File

@@ -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)
}
}

View File

@@ -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)