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) { override suspend fun updateWatchProgress(mediaId: UUID, positionMs: Long, durationMs: Long) {
val repository = onlineRepository onlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
repository.updateWatchProgress(mediaId, positionMs, durationMs) offlineRepository.updateWatchProgress(mediaId, positionMs, durationMs)
} }
override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) { override suspend fun markAsWatched(mediaId: UUID, watched: Boolean) {

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.core.download package hu.bbara.purefin.core.download
import androidx.media3.common.MediaItem
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.UUID import java.util.UUID
@@ -7,6 +8,7 @@ import java.util.UUID
interface MediaDownloadController { interface MediaDownloadController {
fun observeActiveDownloads(): Flow<Map<String, Float>> fun observeActiveDownloads(): Flow<Map<String, Float>>
fun observeDownloadState(contentId: String): StateFlow<DownloadState> fun observeDownloadState(contentId: String): StateFlow<DownloadState>
fun getCompletedDownloadMediaItem(contentId: String): MediaItem?
suspend fun downloadMovie(movieId: UUID) suspend fun downloadMovie(movieId: UUID)
suspend fun cancelDownload(movieId: UUID) suspend fun cancelDownload(movieId: UUID)
suspend fun downloadEpisode(episodeId: UUID) suspend fun downloadEpisode(episodeId: UUID)

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager import androidx.media3.exoplayer.offline.DownloadManager
@@ -109,6 +110,12 @@ class MediaDownloadManager @Inject constructor(
return downloadManager.downloadIndex.getDownload(contentId)?.state == Download.STATE_COMPLETED 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) { override suspend fun downloadMovie(movieId: UUID) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {

View File

@@ -88,7 +88,7 @@ class InMemoryLocalMediaRepository @Inject constructor(
} }
} }
val episode = episodesState.value[id] ?: return flowOf(null) val episode = episodesState.value[id] ?: return flowOf(null)
loadSeasons(seriesId = episode.seriesId) preloadSeasonsForEpisode(episode.seriesId)
return episodesState.map { it[id] }.distinctUntilChanged() return episodesState.map { it[id] }.distinctUntilChanged()
} }
@@ -106,6 +106,26 @@ class InMemoryLocalMediaRepository @Inject constructor(
override suspend fun loadSeasons(seriesId: UUID) { override suspend fun loadSeasons(seriesId: UUID) {
try { 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 } seriesState.value[seriesId]?.takeIf { it.seasons.isNotEmpty() }?.let { return }
val series = seriesState.value[seriesId] ?: throw RuntimeException("Series not found") 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() } seasons = jellyfinApiClient.getSeasons(seriesId).map { it.toSeason() }
) )
seriesState.update { it + (updatedSeries.id to updatedSeries) } 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) { 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.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi 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.PlayableMediaRepository
import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.PlaybackReportContext
import hu.bbara.purefin.core.data.UserSessionRepository 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.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.player.preference.TrackPreferencesRepository 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.JellyfinPlaybackResolver
import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision import hu.bbara.purefin.data.jellyfin.playback.PlaybackDecision
import hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey 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.MediaSegment
import hu.bbara.purefin.model.PlayableMedia import hu.bbara.purefin.model.PlayableMedia
import hu.bbara.purefin.model.SegmentType import hu.bbara.purefin.model.SegmentType
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -40,13 +46,53 @@ class DefaultPlayableMediaRepository @Inject constructor(
private val jellyfinPlaybackResolver: JellyfinPlaybackResolver, private val jellyfinPlaybackResolver: JellyfinPlaybackResolver,
private val trackPreferencesRepository: TrackPreferencesRepository, private val trackPreferencesRepository: TrackPreferencesRepository,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val mediaDownloadController: MediaDownloadController,
private val networkMonitor: NetworkMonitor,
@param:Offline private val offlineMediaRepository: LocalMediaRepository,
) : PlayableMediaRepository { ) : PlayableMediaRepository {
override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) { override suspend fun getPlayableMedia(mediaId: UUID): PlayableMedia? = withContext(Dispatchers.IO) {
val baseItem = jellyfinApiClient.getItemInfo(mediaId) ?: return@withContext null val downloadedMediaItem = mediaDownloadController.getCompletedDownloadMediaItem(mediaId.toString())
val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(mediaId) ?: return@withContext null 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 resumePositionMs = calculateResumePosition(baseItem, playbackDecision.mediaSource)
val preferenceMediaId = when (baseItem.type) { val preferenceMediaId = when (baseItem.type) {
BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId BaseItemKind.EPISODE -> baseItem.seriesId ?: mediaId
@@ -54,7 +100,7 @@ class DefaultPlayableMediaRepository @Inject constructor(
} }
val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(preferenceMediaId.toString()).first() val mediaTrackPreferences = trackPreferencesRepository.getMediaPreferences(preferenceMediaId.toString()).first()
val mediaSegments = getMediaSegments(mediaId) val mediaSegments = getMediaSegments(mediaId)
when (baseItem.type) { return when (baseItem.type) {
BaseItemKind.MOVIE -> PlayableMedia.Movie( BaseItemKind.MOVIE -> PlayableMedia.Movie(
id = mediaId, id = mediaId,
preferenceMediaId = preferenceMediaId, 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) { private suspend fun getMediaItem(baseItem: BaseItemDto, playbackDecision: PlaybackDecision): MediaItem = withContext(Dispatchers.IO) {
val mediaId = baseItem.id val mediaId = baseItem.id
val baseItem = jellyfinApiClient.getItemInfo(mediaId) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
@@ -102,6 +191,25 @@ class DefaultPlayableMediaRepository @Inject constructor(
return@withContext mediaItem 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> { private suspend fun getMediaSegments(mediaId: UUID): List<MediaSegment> {
val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId) val mediaSegments = jellyfinApiClient.getMediaSegments(mediaId)
return mediaSegments.mapNotNull { return mediaSegments.mapNotNull {
@@ -180,6 +288,28 @@ class DefaultPlayableMediaRepository @Inject constructor(
return "S$seasonNumber:E$episodeNumber" 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 { private fun MediaSegmentDto.toMediaSegment(): MediaSegment {
val segmentType = when (type) { val segmentType = when (type) {
INTRO -> SegmentType.INTRO 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.toSeason
import hu.bbara.purefin.data.converter.toSeries import hu.bbara.purefin.data.converter.toSeries
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient 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 hu.bbara.purefin.data.jellyfin.playback.playbackCustomCacheKey
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -24,6 +24,7 @@ import javax.inject.Singleton
class JellyfinDownloadMediaSourceResolver @Inject constructor( class JellyfinDownloadMediaSourceResolver @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, private val jellyfinApiClient: JellyfinApiClient,
private val userSessionRepository: UserSessionRepository, private val userSessionRepository: UserSessionRepository,
private val jellyfinPlaybackResolver: JellyfinPlaybackResolver,
) : DownloadMediaSourceResolver { ) : DownloadMediaSourceResolver {
override suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource? = withContext(Dispatchers.IO) { override suspend fun resolveMovieDownload(movieId: UUID): MovieDownloadSource? = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first().trim() val serverUrl = userSessionRepository.serverUrl.first().trim()
@@ -31,14 +32,13 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
return@withContext null return@withContext null
} }
val mediaSource = jellyfinApiClient.getMediaSources(movieId).firstOrNull() ?: return@withContext null val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(movieId) ?: return@withContext null
val playbackUrl = resolvePlaybackUrl(movieId, mediaSource, serverUrl) ?: return@withContext null
val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null val itemInfo = jellyfinApiClient.getItemInfo(movieId) ?: return@withContext null
MovieDownloadSource( MovieDownloadSource(
movie = itemInfo.toMovie(serverUrl), movie = itemInfo.toMovie(serverUrl),
playbackUrl = playbackUrl, playbackUrl = playbackDecision.url,
customCacheKey = mediaSource.downloadCustomCacheKey(movieId, playbackUrl), customCacheKey = playbackDecision.downloadCustomCacheKey(movieId),
) )
} }
@@ -48,8 +48,7 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
return@withContext null return@withContext null
} }
val mediaSource = jellyfinApiClient.getMediaSources(episodeId).firstOrNull() ?: return@withContext null val playbackDecision = jellyfinPlaybackResolver.getPlaybackDecision(episodeId) ?: return@withContext null
val playbackUrl = resolvePlaybackUrl(episodeId, mediaSource, serverUrl) ?: return@withContext null
val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null val episodeDto = jellyfinApiClient.getItemInfo(episodeId) ?: return@withContext null
val episode = episodeDto.toEpisode(serverUrl) val episode = episodeDto.toEpisode(serverUrl)
val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null val series = jellyfinApiClient.getItemInfo(episode.seriesId)?.toSeries(serverUrl) ?: return@withContext null
@@ -59,8 +58,8 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
episode = episode, episode = episode,
series = series, series = series,
season = season, season = season,
playbackUrl = playbackUrl, playbackUrl = playbackDecision.url,
customCacheKey = mediaSource.downloadCustomCacheKey(episodeId, playbackUrl), customCacheKey = playbackDecision.downloadCustomCacheKey(episodeId),
) )
} }
@@ -92,37 +91,14 @@ class JellyfinDownloadMediaSourceResolver @Inject constructor(
.map { it.id } .map { it.id }
} }
private fun resolvePlaybackUrl( private fun PlaybackDecision.downloadCustomCacheKey(mediaId: UUID): String? {
mediaId: UUID, if (reportContext.playMethod != PlaybackMethod.DIRECT_PLAY) {
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) {
return null return null
} }
return playbackCustomCacheKey( return playbackCustomCacheKey(
mediaId = mediaId.toString(), mediaId = mediaId.toString(),
playbackUrl = playbackUrl, playbackUrl = url,
playMethod = PlaybackMethod.DIRECT_PLAY, playMethod = reportContext.playMethod,
) )
} }
} }