feat: enhance media playback handling with offline support and download metadata integration

This commit is contained in:
2026-05-14 14:38:24 +02:00
parent a0a51ef436
commit 6cff6be238
6 changed files with 181 additions and 52 deletions

View File

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

View File

@@ -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<Map<String, Float>>
fun observeDownloadState(contentId: String): StateFlow<DownloadState>
fun getCompletedDownloadMediaItem(contentId: String): MediaItem?
suspend fun downloadMovie(movieId: UUID)
suspend fun cancelDownload(movieId: UUID)
suspend fun downloadEpisode(episodeId: UUID)

View File

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

View File

@@ -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,6 +106,26 @@ class InMemoryLocalMediaRepository @Inject constructor(
override suspend fun loadSeasons(seriesId: UUID) {
try {
loadSeasonsInternal(seriesId)
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error)
throw error
}
}
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")
@@ -114,12 +134,6 @@ class InMemoryLocalMediaRepository @Inject constructor(
seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
)
seriesState.update { it + (updatedSeries.id to updatedSeries) }
} catch (error: CancellationException) {
throw error
} catch (error: Exception) {
Log.e("InMemoryMediaRepository", "Failed to load content for series $seriesId", error)
throw error
}
}
override suspend fun loadSeasonEpisodes(seriesId: UUID, seasonId: UUID) {

View File

@@ -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<MediaSegment> {
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

View File

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