mirror of
https://github.com/bbara04/Purefin.git
synced 2026-03-31 17:10:08 +02:00
Add offline playback and network fallbacks
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user