From 92342f1f3587d1c85bdf9e0ebe76ac86256b2085 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Tue, 17 Mar 2026 18:20:42 +0100 Subject: [PATCH] Add offline playback and network fallbacks --- .../core/data/InMemoryAppContentRepository.kt | 78 +++++++-- .../core/player/data/PlayerMediaRepository.kt | 163 +++++++++++++----- 2 files changed, 179 insertions(+), 62 deletions(-) diff --git a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt index 3a07634..5c98887 100644 --- a/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt +++ b/core/data/src/main/java/hu/bbara/purefin/core/data/InMemoryAppContentRepository.kt @@ -1,5 +1,6 @@ package hu.bbara.purefin.core.data +import android.util.Log import androidx.datastore.core.DataStore import hu.bbara.purefin.core.data.cache.CachedMediaItem import hu.bbara.purefin.core.data.cache.HomeCache @@ -159,7 +160,11 @@ class InMemoryAppContentRepository @Inject constructor( } suspend fun loadLibraries() { - val librariesItem = jellyfinApiClient.getLibraries() + val librariesItem = runCatching { jellyfinApiClient.getLibraries() } + .getOrElse { error -> + Log.w(TAG, "Unable to load libraries", error) + return + } //TODO add support for playlists val filteredLibraries = librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } @@ -179,7 +184,11 @@ class InMemoryAppContentRepository @Inject constructor( } suspend fun loadLibrary(library: Library): Library { - val contentItem = jellyfinApiClient.getLibraryContent(library.id) + val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) } + .getOrElse { error -> + Log.w(TAG, "Unable to load library ${library.id}", error) + return library + } when (library.type) { CollectionType.MOVIES -> { val movies = contentItem.map { it.toMovie(serverUrl(), library.id) } @@ -194,23 +203,37 @@ class InMemoryAppContentRepository @Inject constructor( } suspend fun loadMovie(movieId: UUID): Movie { - val movieItem = jellyfinApiClient.getItemInfo(movieId) - ?: throw RuntimeException("Movie not found") + val cachedMovie = mediaRepository.movies.value[movieId] + val movieItem = runCatching { jellyfinApiClient.getItemInfo(movieId) } + .getOrElse { error -> + Log.w(TAG, "Unable to load movie $movieId", error) + null + } + ?: return cachedMovie ?: throw RuntimeException("Movie not found") val updatedMovie = movieItem.toMovie(serverUrl(), movieItem.parentId!!) mediaRepository.upsertMovies(listOf(updatedMovie)) return updatedMovie } suspend fun loadSeries(seriesId: UUID): Series { - val seriesItem = jellyfinApiClient.getItemInfo(seriesId) - ?: throw RuntimeException("Series not found") + val cachedSeries = mediaRepository.series.value[seriesId] + val seriesItem = runCatching { jellyfinApiClient.getItemInfo(seriesId) } + .getOrElse { error -> + Log.w(TAG, "Unable to load series $seriesId", error) + null + } + ?: return cachedSeries ?: throw RuntimeException("Series not found") val updatedSeries = seriesItem.toSeries(serverUrl(), seriesItem.parentId!!) mediaRepository.upsertSeries(listOf(updatedSeries)) return updatedSeries } suspend fun loadContinueWatching() { - val continueWatchingItems = jellyfinApiClient.getContinueWatching() + val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } + .getOrElse { error -> + Log.w(TAG, "Unable to load continue watching", error) + return + } val items = continueWatchingItems.mapNotNull { item -> when (item.type) { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) @@ -236,7 +259,11 @@ class InMemoryAppContentRepository @Inject constructor( } suspend fun loadNextUp() { - val nextUpItems = jellyfinApiClient.getNextUpEpisodes() + val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() } + .getOrElse { error -> + Log.w(TAG, "Unable to load next up", error) + return + } val items = nextUpItems.map { item -> Media.EpisodeMedia( episodeId = item.id, @@ -254,11 +281,19 @@ class InMemoryAppContentRepository @Inject constructor( suspend fun loadLatestLibraryContent() { // TODO Make libraries accessible in a field or something that is not this ugly. - val librariesItem = jellyfinApiClient.getLibraries() + val librariesItem = runCatching { jellyfinApiClient.getLibraries() } + .getOrElse { error -> + Log.w(TAG, "Unable to load latest library content", error) + return + } val filterLibraries = librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } val latestLibraryContents = filterLibraries.associate { library -> - val latestFromLibrary = jellyfinApiClient.getLatestFromLibrary(library.id) + val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } + .getOrElse { error -> + Log.w(TAG, "Unable to load latest items for library ${library.id}", error) + emptyList() + } library.id to when (library.collectionType) { CollectionType.MOVIES -> { latestFromLibrary.map { @@ -293,19 +328,22 @@ class InMemoryAppContentRepository @Inject constructor( } override suspend fun refreshHomeData() { - val isOnline = networkMonitor.isOnline.first() - if (!isOnline) return - if(loadJob?.isActive == true) { loadJob?.join() return } val job = scope.launch { - loadLibraries() - loadContinueWatching() - loadNextUp() - loadLatestLibraryContent() - persistHomeCache() + runCatching { + val isOnline = networkMonitor.isOnline.first() + if (!isOnline) return@runCatching + loadLibraries() + loadContinueWatching() + loadNextUp() + loadLatestLibraryContent() + persistHomeCache() + }.onFailure { error -> + Log.w(TAG, "Home refresh failed; keeping cached content", error) + } } loadJob = job job.join() @@ -445,4 +483,8 @@ class InMemoryAppContentRepository @Inject constructor( "${minutes}m" } } + + companion object { + private const val TAG = "InMemoryAppContentRepo" + } } diff --git a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt index d90ac58..48d112e 100644 --- a/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt +++ b/core/player/src/main/java/hu/bbara/purefin/core/player/data/PlayerMediaRepository.kt @@ -8,7 +8,10 @@ import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager import dagger.hilt.android.scopes.ViewModelScoped +import hu.bbara.purefin.core.data.MediaRepository import hu.bbara.purefin.core.data.client.JellyfinApiClient import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.session.UserSessionRepository @@ -26,36 +29,13 @@ import javax.inject.Inject @ViewModelScoped class PlayerMediaRepository @Inject constructor( private val jellyfinApiClient: JellyfinApiClient, - private val userSessionRepository: UserSessionRepository + private val userSessionRepository: UserSessionRepository, + private val mediaRepository: MediaRepository, + private val downloadManager: DownloadManager ) { suspend fun getMediaItem(mediaId: UUID): Pair? = withContext(Dispatchers.IO) { - val mediaSources = jellyfinApiClient.getMediaSources(mediaId) - val selectedMediaSource = mediaSources.firstOrNull() ?: return@withContext null - val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( - mediaId = mediaId, - mediaSource = selectedMediaSource - ) ?: return@withContext null - val baseItem = jellyfinApiClient.getItemInfo(mediaId) - - // Calculate resume position - val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource) - - val serverUrl = userSessionRepository.serverUrl.first() - val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) - - val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource) - - val mediaItem = createMediaItem( - mediaId = mediaId.toString(), - playbackUrl = playbackUrl, - title = baseItem?.name ?: selectedMediaSource.name!!, - subtitle = "S${baseItem!!.parentIndexNumber}:E${baseItem.indexNumber}", - artworkUrl = artworkUrl, - subtitleConfigurations = subtitleConfigs - ) - - Pair(mediaItem, resumePositionMs) + buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(mediaId) } private fun calculateResumePosition( @@ -83,30 +63,125 @@ class PlayerMediaRepository @Inject constructor( } suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set, count: Int = 5): List = withContext(Dispatchers.IO) { - val serverUrl = userSessionRepository.serverUrl.first() - val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) - episodes.mapNotNull { episode -> - val id = episode.id ?: return@mapNotNull null - val stringId = id.toString() - if (existingIds.contains(stringId)) { - return@mapNotNull null + runCatching { + val serverUrl = userSessionRepository.serverUrl.first() + val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) + episodes.mapNotNull { episode -> + val id = episode.id ?: return@mapNotNull null + val stringId = id.toString() + if (existingIds.contains(stringId)) { + return@mapNotNull null + } + val mediaSources = jellyfinApiClient.getMediaSources(id) + val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null + val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( + mediaId = id, + mediaSource = selectedMediaSource + ) ?: return@mapNotNull null + val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) + val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource) + createMediaItem( + mediaId = stringId, + playbackUrl = playbackUrl, + title = episode.name ?: selectedMediaSource.name!!, + subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", + artworkUrl = artworkUrl, + subtitleConfigurations = subtitleConfigs + ) } - val mediaSources = jellyfinApiClient.getMediaSources(id) - val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null + }.getOrElse { error -> + Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error) + emptyList() + } + } + + private suspend fun buildOnlineMediaItem(mediaId: UUID): Pair? { + return runCatching { + val mediaSources = jellyfinApiClient.getMediaSources(mediaId) + val selectedMediaSource = mediaSources.firstOrNull() ?: return null val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( - mediaId = id, + mediaId = mediaId, mediaSource = selectedMediaSource - ) ?: return@mapNotNull null - val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) - val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource) - createMediaItem( - mediaId = stringId, + ) ?: return null + val baseItem = jellyfinApiClient.getItemInfo(mediaId) + + val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource) + val serverUrl = userSessionRepository.serverUrl.first() + val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) + val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, mediaId, selectedMediaSource) + + val mediaItem = createMediaItem( + mediaId = mediaId.toString(), playbackUrl = playbackUrl, - title = episode.name ?: selectedMediaSource.name!!, - subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", + title = baseItem?.name ?: selectedMediaSource.name.orEmpty(), + subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) }, artworkUrl = artworkUrl, subtitleConfigurations = subtitleConfigs ) + + mediaItem to resumePositionMs + }.getOrElse { error -> + Log.w("PlayerMediaRepo", "Falling back to offline playback for $mediaId", error) + null + } + } + + private suspend fun buildOfflineMediaItem(mediaId: UUID): Pair? { + val download = downloadManager.downloadIndex.getDownload(mediaId.toString()) + ?.takeIf { it.state == Download.STATE_COMPLETED } + ?: return null + + val serverUrl = userSessionRepository.serverUrl.first() + val movie = mediaRepository.movies.value[mediaId] + val episode = mediaRepository.episodes.value[mediaId] + + val title = movie?.title ?: episode?.title ?: String(download.request.data, Charsets.UTF_8).ifBlank { + "Offline media" + } + val subtitle = episode?.let { episodeSubtitle(null, it.index) } + val artworkUrl = when { + movie != null -> movie.heroImageUrl + episode != null -> episode.heroImageUrl + else -> JellyfinImageHelper.toImageUrl(serverUrl, mediaId, ImageType.PRIMARY) + } + val resumePositionMs = resumePositionFor(movie, episode) + + val mediaItem = createMediaItem( + mediaId = mediaId.toString(), + playbackUrl = download.request.uri.toString(), + title = title, + subtitle = subtitle, + artworkUrl = artworkUrl, + ) + return mediaItem to resumePositionMs + } + + private fun resumePositionFor(movie: hu.bbara.purefin.core.model.Movie?, episode: hu.bbara.purefin.core.model.Episode?): Long? { + val progress = movie?.progress ?: episode?.progress ?: return null + val runtime = movie?.runtime ?: episode?.runtime ?: return null + val durationMs = parseRuntimeToMs(runtime) ?: return null + if (durationMs <= 0L) return null + return (durationMs * (progress / 100.0)).toLong().takeIf { it > 0L } + } + + private fun parseRuntimeToMs(runtime: String): Long? { + val trimmed = runtime.trim() + if (trimmed.isBlank() || trimmed == "—") return null + + val hourMatch = Regex("(\\d+)h").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L + val minuteMatch = Regex("(\\d+)m").find(trimmed)?.groupValues?.get(1)?.toLongOrNull() ?: 0L + val totalMinutes = hourMatch * 60L + minuteMatch + return if (totalMinutes > 0L) totalMinutes * 60_000L else null + } + + private fun episodeSubtitle(seasonNumber: Int?, episodeNumber: Int?): String? { + if (seasonNumber == null && episodeNumber == null) return null + return buildString { + if (seasonNumber != null) append("S").append(seasonNumber) + if (episodeNumber != null) { + if (isNotEmpty()) append(":") + append("E").append(episodeNumber) + } } }