From 6cff6be238f1d2a65154e4bb8b686c11ea4281c2 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Thu, 14 May 2026 14:38:24 +0200 Subject: [PATCH] feat: enhance media playback handling with offline support and download metadata integration --- .../data/CompositeLocalMediaRepository.kt | 4 +- .../core/download/MediaDownloadController.kt | 2 + .../core/download/MediaDownloadManager.kt | 7 + .../catalog/InMemoryLocalMediaRepository.kt | 32 ++-- .../DefaultPlayableMediaRepository.kt | 138 +++++++++++++++++- .../JellyfinDownloadMediaSourceResolver.kt | 50 ++----- 6 files changed, 181 insertions(+), 52 deletions(-) diff --git a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt index 70024284..5f45b136 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/CompositeLocalMediaRepository.kt @@ -68,8 +68,8 @@ class CompositeLocalMediaRepository @Inject constructor( } override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) { - val repository = onlineRepository - repository.updateWatchProgress(mediaId, positionMs, durationMs) + onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) + offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs) } override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { diff --git a/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadController.kt b/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadController.kt index 2530a6c6..27e841da 100644 --- a/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadController.kt +++ b/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadController.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.core.download +import androidx.media3.common.MediaItem import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import java.util.UUID @@ -7,6 +8,7 @@ import java.util.UUID interface MediaDownloadController { fun observeActiveDownloads(): Flow> fun observeDownloadState(contentId: String): StateFlow + fun getCompletedDownloadMediaItem(contentId: String): MediaItem? suspend fun downloadMovie(movieId: UUID) suspend fun cancelDownload(movieId: UUID) suspend fun downloadEpisode(episodeId: UUID) diff --git a/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt b/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt index d1d734e4..116fe5fa 100644 --- a/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt +++ b/core/src/main/java/hu/bbara/purefin/core/download/MediaDownloadManager.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import androidx.annotation.OptIn import androidx.core.net.toUri +import androidx.media3.common.MediaItem import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.DownloadManager @@ -109,6 +110,12 @@ class MediaDownloadManager @Inject constructor( return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED } + override fun getCompletedDownloadMediaItem(contentId: String): MediaItem? { + val download = downloadManager.downloadIndex.getDownload(contentId) ?: return null + if (download.state != Download.STATE_COMPLETED) return null + return download.request.toMediaItem() + } + override suspend fun downloadMovie(movieId: UUID) { withContext(Dispatchers.IO) { try { diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt index 7be7dd92..36ce9434 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryLocalMediaRepository.kt @@ -88,7 +88,7 @@ class InMemoryLocalMediaRepository @Inject constructor( } } val episode = episodesState.value[id] ?: return flowOf(null) - loadSeasons(seriesId = episode.seriesId) + preloadSeasonsForEpisode(episode.seriesId) return episodesState.map { it[id] }.distinctUntilChanged() } @@ -106,14 +106,7 @@ class InMemoryLocalMediaRepository @Inject constructor( override suspend fun loadSeasons(seriesId: UUID) { try { - seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return } - - val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") - - val updatedSeries = series.copy( - seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } - ) - seriesState.update { it + (updatedSeries.id to updatedSeries) } + loadSeasonsInternal(seriesId) } catch (error: CancellationException) { throw error } catch (error: Exception) { @@ -122,6 +115,27 @@ class InMemoryLocalMediaRepository @Inject constructor( } } + private suspend fun preloadSeasonsForEpisode(seriesId: UUID) { + try { + loadSeasonsInternal(seriesId) + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + Log.w("InMemoryMediaRepository", "Unable to preload seasons for episode series $seriesId", error) + } + } + + private suspend fun loadSeasonsInternal(seriesId: UUID) { + seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return } + + val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") + + val updatedSeries = series.copy( + seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() } + ) + seriesState.update { it + (updatedSeries.id to updatedSeries) } + } + override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) { loadSeasons(seriesId) diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt index 29a5e825..a036ee0c 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/DefaultPlayableMediaRepository.kt @@ -6,9 +6,13 @@ import androidx.core.net.toUri import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.util.UnstableApi +import hu.bbara.purefin.core.Offline +import hu.bbara.purefin.core.data.LocalMediaRepository +import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.PlayableMediaRepository import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.UserSessionRepository +import hu.bbara.purefin.core.download.MediaDownloadController import hu.bbara.purefin.core.image.ArtworkKind import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository @@ -16,9 +20,11 @@ import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey +import hu.bbara.purefin.model.Episode import hu.bbara.purefin.model.MediaSegment import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.SegmentType +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -40,13 +46,53 @@ class DefaultPlayableMediaRepository @Inject constructor( private val jellyfinPlaybackResolver: JellyfinPlaybackResolver, private val trackPreferencesRepository: TrackPreferencesRepository, private val userSessionRepository: UserSessionRepository, + private val mediaDownloadController: MediaDownloadController, + private val networkMonitor: NetworkMonitor, + @param:Offline private val offlineMediaRepository: LocalMediaRepository, ) : PlayableMediaRepository { override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) { - val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return@withContext null - val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null + val downloadedMediaItem = mediaDownloadController.getCompletedDownloadMediaItem(mediaId.toString()) + if (downloadedMediaItem != null) { + return@withContext getDownloadedPlayableMedia(mediaId, downloadedMediaItem) + } + getStreamingPlayableMedia(mediaId) + } - val mediaItem = getMediaItem(baseItem, playbackDecision) + private suspend fun getDownloadedPlayableMedia( + mediaId: UUID, + downloadedMediaItem: MediaItem, + ): PlayableMedia? { + if (!networkMonitor.isOnline.first()) { + return getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) + } + + return runCatching { + getOnlinePlayableMedia(mediaId, downloadedMediaItem) + ?: getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) + }.getOrElse { error -> + if (error is CancellationException) throw error + Log.w("PlayableMediaRepo", "Unable to load online metadata for downloaded media $mediaId", error) + getOfflineDownloadedPlayableMedia(mediaId, downloadedMediaItem) + } + } + + private suspend fun getStreamingPlayableMedia(mediaId: UUID): PlayableMedia? { + return getOnlinePlayableMedia(mediaId, downloadedMediaItem = null) + } + + private suspend fun getOnlinePlayableMedia( + mediaId: UUID, + downloadedMediaItem: MediaItem?, + ): PlayableMedia? { + val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return null + val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return null + + val mediaItem = if (downloadedMediaItem == null) { + getMediaItem(baseItem, playbackDecision) + } else { + getDownloadedMediaItem(baseItem, playbackDecision, downloadedMediaItem) + } val resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource) val preferenceMediaId = when (baseItem.type) { BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId @@ -54,7 +100,7 @@ class DefaultPlayableMediaRepository @Inject constructor( } val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(preferenceMediaId.toString()).first() val mediaSegments = getMediaSegments(mediaId) - when (baseItem.type) { + return when (baseItem.type) { BaseItemKind.MOVIE -> PlayableMedia.Movie( id = mediaId, preferenceMediaId = preferenceMediaId, @@ -83,6 +129,49 @@ class DefaultPlayableMediaRepository @Inject constructor( } } + private suspend fun getOfflineDownloadedPlayableMedia( + mediaId: UUID, + downloadedMediaItem: MediaItem, + ): PlayableMedia? { + offlineMediaRepository.getMovie(mediaId).first()?.let { movie -> + val preferences = trackPreferencesRepository.getMediaPreferences(mediaId.toString()).first() + return PlayableMedia.Movie( + id = mediaId, + preferenceMediaId = mediaId, + mediaItem = downloadedMediaItem.withMetadata( + mediaId = mediaId.toString(), + title = movie.title, + subtitle = null, + artworkUrl = ImageUrlBuilder.finishImageUrl(movie.imageUrlPrefix, ArtworkKind.PRIMARY), + playbackReportContext = null, + ), + resumePositionMs = 0L, + preferences = preferences, + mediaSegments = emptyList() + ) + } + + offlineMediaRepository.getEpisode(mediaId).first()?.let { episode -> + val preferences = trackPreferencesRepository.getMediaPreferences(episode.seriesId.toString()).first() + return PlayableMedia.Episode( + id = mediaId, + preferenceMediaId = episode.seriesId, + mediaItem = downloadedMediaItem.withMetadata( + mediaId = mediaId.toString(), + title = episode.title, + subtitle = episode.seasonEpisodeLabel(), + artworkUrl = ImageUrlBuilder.finishImageUrl(episode.imageUrlPrefix, ArtworkKind.PRIMARY), + playbackReportContext = null, + ), + resumePositionMs = 0L, + preferences = preferences, + mediaSegments = emptyList() + ) + } + + return null + } + private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) { val mediaId = baseItem.id val baseItem = jellyfinApiClient.getItemInfo(mediaId) @@ -102,6 +191,25 @@ class DefaultPlayableMediaRepository @Inject constructor( return@withContext mediaItem } + private suspend fun getDownloadedMediaItem( + baseItem: BaseItemDto, + playbackDecision: PlaybackDecision, + downloadedMediaItem: MediaItem, + ): MediaItem = withContext(Dispatchers.IO) { + val mediaId = baseItem.id + val baseItem = jellyfinApiClient.getItemInfo(mediaId) + val serverUrl = userSessionRepository.serverUrl.first() + val artworkUrl = ImageUrlBuilder.toImageUrl(serverUrl, mediaId, ArtworkKind.PRIMARY) + + return@withContext downloadedMediaItem.withMetadata( + mediaId = mediaId.toString(), + title = baseItem?.name ?: playbackDecision.mediaSource.name ?: "Unknown", + subtitle = seasonEpisodeLabel(baseItem), + artworkUrl = artworkUrl, + playbackReportContext = playbackDecision.reportContext, + ) + } + private suspend fun getMediaSegments(mediaId: UUID): List { val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId) return mediaSegments.mapNotNull { @@ -180,6 +288,28 @@ class DefaultPlayableMediaRepository @Inject constructor( return "S$seasonNumber:E$episodeNumber" } + private fun Episode.seasonEpisodeLabel(): String = "S$seasonIndex:E$index" + + private fun MediaItem.withMetadata( + mediaId: String, + title: String, + subtitle: String?, + artworkUrl: String, + playbackReportContext: PlaybackReportContext?, + ): MediaItem { + val metadataBuilder = MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + if (artworkUrl.isNotBlank()) { + metadataBuilder.setArtworkUri(artworkUrl.toUri()) + } + return buildUpon() + .setMediaId(mediaId) + .setMediaMetadata(metadataBuilder.build()) + .setTag(playbackReportContext) + .build() + } + private fun MediaSegmentDto.toMediaSegment(): MediaSegment { val segmentType = when (type) { INTRO -> SegmentType.INTRO diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt index ad38a046..5d6f259b 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/download/JellyfinDownloadMediaSourceResolver.kt @@ -10,12 +10,12 @@ import hu.bbara.purefin.data.converter.toMovie import hu.bbara.purefin.data.converter.toSeason import hu.bbara.purefin.data.converter.toSeries import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient -import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecisionResolver +import hu.bbara.purefin.data.jellyfin.playback.JellyfinPlaybackResolver +import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext -import org.jellyfin.sdk.model.api.MediaSourceInfo import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -24,6 +24,7 @@ import javax.inject.Singleton class JellyfinDownloadMediaSourceResolver @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, private val userSessionRepository: UserSessionRepository, + private val jellyfinPlaybackResolver: JellyfinPlaybackResolver, ) : DownloadMediaSourceResolver { override suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource? = withContext(Dispatchers.IO) { val serverUrl = userSessionRepository.serverUrl.first().trim() @@ -31,14 +32,13 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor( return@withContext null } - val mediaSource = jellyfinApiClient.getMediaSources(movieId).firstOrNull() ?: return@withContext null - val playbackUrl = resolvePlaybackUrl(movieId, mediaSource, serverUrl) ?: return@withContext null + val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(movieId) ?: return@withContext null val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null MovieDownloadSource( movie = itemInfo.toMovie(serverUrl), - playbackUrl = playbackUrl, - customCacheKey = mediaSource.downloadCustomCacheKey(movieId, playbackUrl), + playbackUrl = playbackDecision.url, + customCacheKey = playbackDecision.downloadCustomCacheKey(movieId), ) } @@ -48,8 +48,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor( return@withContext null } - val mediaSource = jellyfinApiClient.getMediaSources(episodeId).firstOrNull() ?: return@withContext null - val playbackUrl = resolvePlaybackUrl(episodeId, mediaSource, serverUrl) ?: return@withContext null + val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(episodeId) ?: return@withContext null val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null val episode = episodeDto.toEpisode(serverUrl) val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null @@ -59,8 +58,8 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor( episode = episode, series = series, season = season, - playbackUrl = playbackUrl, - customCacheKey = mediaSource.downloadCustomCacheKey(episodeId, playbackUrl), + playbackUrl = playbackDecision.url, + customCacheKey = playbackDecision.downloadCustomCacheKey(episodeId), ) } @@ -92,37 +91,14 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor( .map { it.id } } - private fun resolvePlaybackUrl( - mediaId: UUID, - mediaSource: MediaSourceInfo, - serverUrl: String, - ): String? { - val shouldTranscode = mediaSource.supportsTranscoding == true && - (mediaSource.supportsDirectPlay == false || mediaSource.transcodingUrl != null) - - return if (shouldTranscode && !mediaSource.transcodingUrl.isNullOrBlank()) { - PlaybackDecisionResolver.absolutePlaybackUrl(serverUrl, requireNotNull(mediaSource.transcodingUrl)) - } else { - jellyfinApiClient.getVideoStreamUrl( - itemId = mediaId, - mediaSourceId = mediaSource.id, - ) - } - } - - private fun MediaSourceInfo.downloadCustomCacheKey( - mediaId: UUID, - playbackUrl: String, - ): String? { - val shouldTranscode = supportsTranscoding == true && - (supportsDirectPlay == false || transcodingUrl != null) - if (shouldTranscode) { + private fun PlaybackDecision.downloadCustomCacheKey(mediaId: UUID): String? { + if (reportContext.playMethod != PlaybackMethod.DIRECT_PLAY) { return null } return playbackCustomCacheKey( mediaId = mediaId.toString(), - playbackUrl = playbackUrl, - playMethod = PlaybackMethod.DIRECT_PLAY, + playbackUrl = url, + playMethod = reportContext.playMethod, ) } }