Add offline playback and network fallbacks

This commit is contained in:
2026-03-17 18:20:42 +01:00
parent 08cc4589c2
commit 92342f1f35
2 changed files with 179 additions and 62 deletions

View File

@@ -1,5 +1,6 @@
package hu.bbara.purefin.core.data package hu.bbara.purefin.core.data
import android.util.Log
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.cache.CachedMediaItem import hu.bbara.purefin.core.data.cache.CachedMediaItem
import hu.bbara.purefin.core.data.cache.HomeCache import hu.bbara.purefin.core.data.cache.HomeCache
@@ -159,7 +160,11 @@ class InMemoryAppContentRepository @Inject constructor(
} }
suspend fun loadLibraries() { 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 //TODO add support for playlists
val filteredLibraries = val filteredLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
@@ -179,7 +184,11 @@ class InMemoryAppContentRepository @Inject constructor(
} }
suspend fun loadLibrary(library: Library): Library { 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) { when (library.type) {
CollectionType.MOVIES -> { CollectionType.MOVIES -> {
val movies = contentItem.map { it.toMovie(serverUrl(), library.id) } val movies = contentItem.map { it.toMovie(serverUrl(), library.id) }
@@ -194,23 +203,37 @@ class InMemoryAppContentRepository @Inject constructor(
} }
suspend fun loadMovie(movieId: UUID): Movie { suspend fun loadMovie(movieId: UUID): Movie {
val movieItem = jellyfinApiClient.getItemInfo(movieId) val cachedMovie = mediaRepository.movies.value[movieId]
?: throw RuntimeException("Movie not found") 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!!) val updatedMovie = movieItem.toMovie(serverUrl(), movieItem.parentId!!)
mediaRepository.upsertMovies(listOf(updatedMovie)) mediaRepository.upsertMovies(listOf(updatedMovie))
return updatedMovie return updatedMovie
} }
suspend fun loadSeries(seriesId: UUID): Series { suspend fun loadSeries(seriesId: UUID): Series {
val seriesItem = jellyfinApiClient.getItemInfo(seriesId) val cachedSeries = mediaRepository.series.value[seriesId]
?: throw RuntimeException("Series not found") 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!!) val updatedSeries = seriesItem.toSeries(serverUrl(), seriesItem.parentId!!)
mediaRepository.upsertSeries(listOf(updatedSeries)) mediaRepository.upsertSeries(listOf(updatedSeries))
return updatedSeries return updatedSeries
} }
suspend fun loadContinueWatching() { 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 -> val items = continueWatchingItems.mapNotNull { item ->
when (item.type) { when (item.type) {
BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id)
@@ -236,7 +259,11 @@ class InMemoryAppContentRepository @Inject constructor(
} }
suspend fun loadNextUp() { 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 -> val items = nextUpItems.map { item ->
Media.EpisodeMedia( Media.EpisodeMedia(
episodeId = item.id, episodeId = item.id,
@@ -254,11 +281,19 @@ class InMemoryAppContentRepository @Inject constructor(
suspend fun loadLatestLibraryContent() { suspend fun loadLatestLibraryContent() {
// TODO Make libraries accessible in a field or something that is not this ugly. // 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 = val filterLibraries =
librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } librariesItem.filter { it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS }
val latestLibraryContents = filterLibraries.associate { library -> 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) { library.id to when (library.collectionType) {
CollectionType.MOVIES -> { CollectionType.MOVIES -> {
latestFromLibrary.map { latestFromLibrary.map {
@@ -293,19 +328,22 @@ class InMemoryAppContentRepository @Inject constructor(
} }
override suspend fun refreshHomeData() { override suspend fun refreshHomeData() {
val isOnline = networkMonitor.isOnline.first()
if (!isOnline) return
if(loadJob?.isActive == true) { if(loadJob?.isActive == true) {
loadJob?.join() loadJob?.join()
return return
} }
val job = scope.launch { val job = scope.launch {
loadLibraries() runCatching {
loadContinueWatching() val isOnline = networkMonitor.isOnline.first()
loadNextUp() if (!isOnline) return@runCatching
loadLatestLibraryContent() loadLibraries()
persistHomeCache() loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
} }
loadJob = job loadJob = job
job.join() job.join()
@@ -445,4 +483,8 @@ class InMemoryAppContentRepository @Inject constructor(
"${minutes}m" "${minutes}m"
} }
} }
companion object {
private const val TAG = "InMemoryAppContentRepo"
}
} }

View File

@@ -8,7 +8,10 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi 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 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.client.JellyfinApiClient
import hu.bbara.purefin.core.data.image.JellyfinImageHelper import hu.bbara.purefin.core.data.image.JellyfinImageHelper
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
@@ -26,36 +29,13 @@ import javax.inject.Inject
@ViewModelScoped @ViewModelScoped
class PlayerMediaRepository @Inject constructor( class PlayerMediaRepository @Inject constructor(
private val jellyfinApiClient: JellyfinApiClient, 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<MediaItem, Long?>? = withContext(Dispatchers.IO) { suspend fun getMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? = withContext(Dispatchers.IO) {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId) buildOnlineMediaItem(mediaId) ?: buildOfflineMediaItem(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)
} }
private fun calculateResumePosition( private fun calculateResumePosition(
@@ -83,30 +63,125 @@ class PlayerMediaRepository @Inject constructor(
} }
suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) { suspend fun getNextUpMediaItems(episodeId: UUID, existingIds: Set<String>, count: Int = 5): List<MediaItem> = withContext(Dispatchers.IO) {
val serverUrl = userSessionRepository.serverUrl.first() runCatching {
val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count) val serverUrl = userSessionRepository.serverUrl.first()
episodes.mapNotNull { episode -> val episodes = jellyfinApiClient.getNextEpisodes(episodeId = episodeId, count = count)
val id = episode.id ?: return@mapNotNull null episodes.mapNotNull { episode ->
val stringId = id.toString() val id = episode.id ?: return@mapNotNull null
if (existingIds.contains(stringId)) { val stringId = id.toString()
return@mapNotNull null 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) }.getOrElse { error ->
val selectedMediaSource = mediaSources.firstOrNull() ?: return@mapNotNull null Log.w("PlayerMediaRepo", "Unable to load next-up items for $episodeId", error)
emptyList()
}
}
private suspend fun buildOnlineMediaItem(mediaId: UUID): Pair<MediaItem, Long?>? {
return runCatching {
val mediaSources = jellyfinApiClient.getMediaSources(mediaId)
val selectedMediaSource = mediaSources.firstOrNull() ?: return null
val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl( val playbackUrl = jellyfinApiClient.getMediaPlaybackUrl(
mediaId = id, mediaId = mediaId,
mediaSource = selectedMediaSource mediaSource = selectedMediaSource
) ?: return@mapNotNull null ) ?: return null
val artworkUrl = JellyfinImageHelper.toImageUrl(serverUrl, id, ImageType.PRIMARY) val baseItem = jellyfinApiClient.getItemInfo(mediaId)
val subtitleConfigs = buildExternalSubtitleConfigs(serverUrl, id, selectedMediaSource)
createMediaItem( val resumePositionMs = calculateResumePosition(baseItem, selectedMediaSource)
mediaId = stringId, 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, playbackUrl = playbackUrl,
title = episode.name ?: selectedMediaSource.name!!, title = baseItem?.name ?: selectedMediaSource.name.orEmpty(),
subtitle = "S${episode.parentIndexNumber}:E${episode.indexNumber}", subtitle = baseItem?.let { episodeSubtitle(it.parentIndexNumber, it.indexNumber) },
artworkUrl = artworkUrl, artworkUrl = artworkUrl,
subtitleConfigurations = subtitleConfigs 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<MediaItem, Long?>? {
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)
}
} }
} }