mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-23 19:26:50 +00:00
feat: enhance media playback handling with offline support and download metadata integration
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user