Compare commits

...

2 Commits

3 changed files with 348 additions and 58 deletions

View File

@@ -2,8 +2,8 @@ package hu.bbara.purefin.core.data
import hu.bbara.purefin.core.model.Library import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.Media import hu.bbara.purefin.core.model.Media
import java.util.UUID
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface HomeRepository { interface HomeRepository {
val libraries: StateFlow<List<Library>> val libraries: StateFlow<List<Library>>
@@ -11,6 +11,6 @@ interface HomeRepository {
val continueWatching: StateFlow<List<Media>> val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<List<Media>> val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
suspend fun ensureReady() fun ensureReady()
suspend fun refreshHomeData() fun refreshHomeData()
} }

View File

@@ -4,12 +4,9 @@ import android.util.Log
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.HomeRepository
import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.NetworkMonitor
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.data.offline.cache.CachedMediaItem
import hu.bbara.purefin.data.offline.cache.HomeCache
import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.data.session.UserSessionRepository import hu.bbara.purefin.core.data.session.UserSessionRepository
import hu.bbara.purefin.core.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.LibraryKind import hu.bbara.purefin.core.model.LibraryKind
@@ -17,13 +14,18 @@ import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.model.Movie import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Season import hu.bbara.purefin.core.model.Season
import hu.bbara.purefin.core.model.Series import hu.bbara.purefin.core.model.Series
import java.time.LocalDateTime import hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import java.time.format.DateTimeFormatter import hu.bbara.purefin.data.offline.cache.HomeCache
import java.util.Locale import hu.bbara.purefin.data.offline.cache.toCachedEpisode
import java.util.UUID import hu.bbara.purefin.data.offline.cache.toCachedItem
import java.util.concurrent.TimeUnit import hu.bbara.purefin.data.offline.cache.toCachedLibrary
import javax.inject.Inject import hu.bbara.purefin.data.offline.cache.toCachedMovie
import javax.inject.Singleton import hu.bbara.purefin.data.offline.cache.toCachedSeries
import hu.bbara.purefin.data.offline.cache.toEpisode
import hu.bbara.purefin.data.offline.cache.toLibrary
import hu.bbara.purefin.data.offline.cache.toMedia
import hu.bbara.purefin.data.offline.cache.toMovie
import hu.bbara.purefin.data.offline.cache.toSeries
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -33,11 +35,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.api.CollectionType
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@Singleton @Singleton
class InMemoryAppContentRepository @Inject constructor( class InMemoryAppContentRepository @Inject constructor(
@@ -48,10 +57,10 @@ class InMemoryAppContentRepository @Inject constructor(
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
) : HomeRepository { ) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val ensureReadyMutex = Mutex()
private var refreshJob: Job? = null private var refreshJob: Job? = null
private var cacheHydrated = false
private var initialized = false @OptIn(ExperimentalAtomicApi::class)
private val initialized = AtomicBoolean(false)
private val librariesState = MutableStateFlow<List<Library>>(emptyList()) private val librariesState = MutableStateFlow<List<Library>>(emptyList())
override val libraries: StateFlow<List<Library>> = librariesState.asStateFlow() override val libraries: StateFlow<List<Library>> = librariesState.asStateFlow()
@@ -74,27 +83,24 @@ class InMemoryAppContentRepository @Inject constructor(
} }
} }
override suspend fun ensureReady() { @OptIn(ExperimentalAtomicApi::class)
ensureReadyMutex.withLock { override fun ensureReady() {
hydrateCacheIfNeeded() if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
if (initialized) {
return return
} }
initialized = true Log.d(TAG, "Initializing home repository")
} scope.launch { loadHomeCache() }
refreshHomeData() scope.launch { refreshHomeData() }
} }
override suspend fun refreshHomeData() { @Synchronized
ensureReadyMutex.withLock { override fun refreshHomeData() {
hydrateCacheIfNeeded()
}
if (refreshJob?.isActive == true) { if (refreshJob?.isActive == true) {
refreshJob?.join()
return return
} }
val job = scope.launch { val job = scope.launch {
runCatching { runCatching {
Log.d(TAG, "Refreshing home data")
if (!networkMonitor.isOnline.first()) { if (!networkMonitor.isOnline.first()) {
return@runCatching return@runCatching
} }
@@ -103,23 +109,28 @@ class InMemoryAppContentRepository @Inject constructor(
loadContinueWatching() loadContinueWatching()
loadNextUp() loadNextUp()
loadLatestLibraryContent() loadLatestLibraryContent()
Log.d(TAG, "Home refresh successful")
persistHomeCache() persistHomeCache()
}.onFailure { error -> }.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error) Log.w(TAG, "Home refresh failed; keeping cached content", error)
} }
} }
refreshJob = job refreshJob = job
job.join()
} }
private suspend fun hydrateCacheIfNeeded() { private suspend fun loadHomeCache() {
if (cacheHydrated) return Log.d(TAG, "Loading home cache")
loadFromCache()
cacheHydrated = true
}
private suspend fun loadFromCache() {
val cache = homeCacheDataStore.data.first() val cache = homeCacheDataStore.data.first()
if (cache.libraries.isNotEmpty()) {
val libraries = cache.libraries.mapNotNull { it.toLibrary() }
librariesState.value = libraries
onlineMediaRepository.upsertMovies(
libraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() }
)
onlineMediaRepository.upsertSeries(
libraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() }
)
}
if (cache.suggestions.isNotEmpty()) { if (cache.suggestions.isNotEmpty()) {
suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() } suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() }
} }
@@ -135,9 +146,23 @@ class InMemoryAppContentRepository @Inject constructor(
uuid to items.mapNotNull { it.toMedia() } uuid to items.mapNotNull { it.toMedia() }
}.toMap() }.toMap()
} }
if (cache.movies.isNotEmpty()) {
onlineMediaRepository.upsertMovies(cache.movies.mapNotNull { it.toMovie() })
}
if (cache.series.isNotEmpty()) {
onlineMediaRepository.upsertSeries(cache.series.mapNotNull { it.toSeries() })
}
if (cache.episodes.isNotEmpty()) {
onlineMediaRepository.upsertEpisodes(cache.episodes.mapNotNull { it.toEpisode() })
}
Log.d(TAG, "Home cache loaded")
} }
private suspend fun persistHomeCache() { private suspend fun persistHomeCache() {
val referencedMediaIds = collectReferencedMediaIds()
val movies = onlineMediaRepository.movies.value
val series = onlineMediaRepository.series.value
val episodes = onlineMediaRepository.episodes.value
val cache = HomeCache( val cache = HomeCache(
suggestions = suggestionsState.value.map { it.toCachedItem() }, suggestions = suggestionsState.value.map { it.toCachedItem() },
continueWatching = continueWatchingState.value.map { it.toCachedItem() }, continueWatching = continueWatchingState.value.map { it.toCachedItem() },
@@ -145,29 +170,41 @@ class InMemoryAppContentRepository @Inject constructor(
latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) -> latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) ->
uuid.toString() to items.map { it.toCachedItem() } uuid.toString() to items.map { it.toCachedItem() }
}.toMap(), }.toMap(),
libraries = librariesState.value.map { it.toCachedLibrary() },
movies = referencedMediaIds.movieIds.mapNotNull { movies[it] }.map { it.toCachedMovie() },
series = referencedMediaIds.seriesIds.mapNotNull { series[it] }.map { it.toCachedSeries() },
episodes = referencedMediaIds.episodeIds.mapNotNull { episodes[it] }.map { it.toCachedEpisode() },
) )
homeCacheDataStore.updateData { cache } homeCacheDataStore.updateData { cache }
} }
private fun Media.toCachedItem(): CachedMediaItem = when (this) { private fun collectReferencedMediaIds(): ReferencedHomeMediaIds {
is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString()) val movieIds = mutableSetOf<UUID>()
is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString()) val seriesIds = mutableSetOf<UUID>()
is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString()) val episodeIds = mutableSetOf<UUID>()
is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString()) val referencedMedia = buildList {
addAll(suggestionsState.value)
addAll(continueWatchingState.value)
addAll(nextUpState.value)
latestLibraryContentState.value.values.forEach { addAll(it) }
} }
private fun CachedMediaItem.toMedia(): Media? { referencedMedia.forEach { media ->
val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null when (media) {
val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() } is Media.MovieMedia -> movieIds += media.movieId
return when (type) { is Media.SeriesMedia -> seriesIds += media.seriesId
"MOVIE" -> Media.MovieMedia(movieId = uuid) is Media.SeasonMedia -> seriesIds += media.seriesId
"SERIES" -> Media.SeriesMedia(seriesId = uuid) is Media.EpisodeMedia -> episodeIds += media.episodeId
"SEASON" -> Media.SeasonMedia(seasonId = uuid, seriesId = seriesUuid ?: return null)
"EPISODE" -> Media.EpisodeMedia(episodeId = uuid, seriesId = seriesUuid ?: return null)
else -> null
} }
} }
return ReferencedHomeMediaIds(
movieIds = movieIds,
seriesIds = seriesIds,
episodeIds = episodeIds,
)
}
suspend fun loadLibraries() { suspend fun loadLibraries() {
val librariesItem = runCatching { jellyfinApiClient.getLibraries() } val librariesItem = runCatching { jellyfinApiClient.getLibraries() }
.getOrElse { error -> .getOrElse { error ->
@@ -419,3 +456,9 @@ class InMemoryAppContentRepository @Inject constructor(
private const val TAG = "InMemoryAppContentRepo" private const val TAG = "InMemoryAppContentRepo"
} }
} }
private data class ReferencedHomeMediaIds(
val movieIds: Set<UUID>,
val seriesIds: Set<UUID>,
val episodeIds: Set<UUID>,
)

View File

@@ -1,6 +1,70 @@
package hu.bbara.purefin.data.offline.cache package hu.bbara.purefin.data.offline.cache
import hu.bbara.purefin.core.model.CastMember
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
import hu.bbara.purefin.core.model.LibraryKind
import hu.bbara.purefin.core.model.Media
import hu.bbara.purefin.core.model.Movie
import hu.bbara.purefin.core.model.Series
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CachedCastMember(
val name: String,
val role: String,
val imageUrl: String? = null
)
@Serializable
data class CachedMovie(
val id: String,
val libraryId: String,
val title: String,
val progress: Double? = null,
val watched: Boolean,
val year: String,
val rating: String,
val runtime: String,
val format: String,
val synopsis: String,
val imageUrlPrefix: String,
val audioTrack: String,
val subtitles: String,
val cast: List<CachedCastMember> = emptyList()
)
@Serializable
data class CachedSeries(
val id: String,
val libraryId: String,
val name: String,
val synopsis: String,
val year: String,
val imageUrlPrefix: String,
val unwatchedEpisodeCount: Int,
val seasonCount: Int,
val cast: List<CachedCastMember> = emptyList()
)
@Serializable
data class CachedEpisode(
val id: String,
val seriesId: String,
val seasonId: String,
val index: Int,
val title: String,
val synopsis: String,
val releaseDate: String,
val rating: String,
val runtime: String,
val progress: Double? = null,
val watched: Boolean,
val format: String,
val imageUrlPrefix: String,
val cast: List<CachedCastMember> = emptyList()
)
@Serializable @Serializable
data class CachedMediaItem( data class CachedMediaItem(
@@ -9,10 +73,193 @@ data class CachedMediaItem(
val seriesId: String? = null val seriesId: String? = null
) )
@Serializable
data class CachedLibrary(
val id: String,
val name: String,
val type: String,
val posterUrl: String,
val series: List<CachedSeries>? = null,
val movies: List<CachedMovie>? = null,
)
@Serializable @Serializable
data class HomeCache( data class HomeCache(
val suggestions: List<CachedMediaItem> = emptyList(), val suggestions: List<CachedMediaItem> = emptyList(),
val continueWatching: List<CachedMediaItem> = emptyList(), val continueWatching: List<CachedMediaItem> = emptyList(),
val nextUp: List<CachedMediaItem> = emptyList(), val nextUp: List<CachedMediaItem> = emptyList(),
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap() val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
val libraries: List<CachedLibrary> = emptyList(),
val movies: List<CachedMovie> = emptyList(),
val series: List<CachedSeries> = emptyList(),
val episodes: List<CachedEpisode> = emptyList()
) )
fun Library.toCachedLibrary() = CachedLibrary(
id = id.toString(),
name = name,
type = type.name,
posterUrl = posterUrl,
series = series?.map { it.toCachedSeries() },
movies = movies?.map { it.toCachedMovie() },
)
fun CachedLibrary.toLibrary(): Library? {
val libraryId = id.toUuidOrNull() ?: return null
return when (type) {
"MOVIES" -> Library(
id = libraryId,
name = name,
type = LibraryKind.MOVIES,
posterUrl = posterUrl,
movies = movies?.mapNotNull { it.toMovie() } ?: emptyList(),
)
"SERIES" -> Library(
id = libraryId,
name = name,
type = LibraryKind.SERIES,
posterUrl = posterUrl,
series = series?.mapNotNull { it.toSeries() } ?: emptyList(),
)
else -> null
}
}
fun Media.toCachedItem(): CachedMediaItem = when (this) {
is Media.MovieMedia -> CachedMediaItem(type = "MOVIE", id = movieId.toString())
is Media.SeriesMedia -> CachedMediaItem(type = "SERIES", id = seriesId.toString())
is Media.SeasonMedia -> CachedMediaItem(type = "SEASON", id = seasonId.toString(), seriesId = seriesId.toString())
is Media.EpisodeMedia -> CachedMediaItem(type = "EPISODE", id = episodeId.toString(), seriesId = seriesId.toString())
}
fun CachedMediaItem.toMedia(): Media? {
val uuid = runCatching { UUID.fromString(id) }.getOrNull() ?: return null
val seriesUuid = seriesId?.let { runCatching { UUID.fromString(it) }.getOrNull() }
return when (type) {
"MOVIE" -> Media.MovieMedia(movieId = uuid)
"SERIES" -> Media.SeriesMedia(seriesId = uuid)
"SEASON" -> Media.SeasonMedia(seasonId = uuid, seriesId = seriesUuid ?: return null)
"EPISODE" -> Media.EpisodeMedia(episodeId = uuid, seriesId = seriesUuid ?: return null)
else -> null
}
}
fun Movie.toCachedMovie() = CachedMovie(
id = id.toString(),
libraryId = libraryId.toString(),
title = title,
progress = progress,
watched = watched,
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast.map { it.toCachedCastMember() },
)
fun CachedMovie.toMovie(): Movie? {
val movieId = id.toUuidOrNull() ?: return null
val libraryUuid = libraryId.toUuidOrNull() ?: return null
return Movie(
id = movieId,
libraryId = libraryUuid,
title = title,
progress = progress,
watched = watched,
year = year,
rating = rating,
runtime = runtime,
format = format,
synopsis = synopsis,
imageUrlPrefix = imageUrlPrefix,
audioTrack = audioTrack,
subtitles = subtitles,
cast = cast.map { it.toCastMember() },
)
}
fun Series.toCachedSeries() = CachedSeries(
id = id.toString(),
libraryId = libraryId.toString(),
name = name,
synopsis = synopsis,
year = year,
imageUrlPrefix = imageUrlPrefix,
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount,
cast = cast.map { it.toCachedCastMember() },
)
fun CachedSeries.toSeries(): Series? {
val seriesId = id.toUuidOrNull() ?: return null
val libraryUuid = libraryId.toUuidOrNull() ?: return null
return Series(
id = seriesId,
libraryId = libraryUuid,
name = name,
synopsis = synopsis,
year = year,
imageUrlPrefix = imageUrlPrefix,
unwatchedEpisodeCount = unwatchedEpisodeCount,
seasonCount = seasonCount,
seasons = emptyList(),
cast = cast.map { it.toCastMember() },
)
}
fun Episode.toCachedEpisode() = CachedEpisode(
id = id.toString(),
seriesId = seriesId.toString(),
seasonId = seasonId.toString(),
index = index,
title = title,
synopsis = synopsis,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
progress = progress,
watched = watched,
format = format,
imageUrlPrefix = imageUrlPrefix,
cast = cast.map { it.toCachedCastMember() },
)
fun CachedEpisode.toEpisode(): Episode? {
val episodeId = id.toUuidOrNull() ?: return null
val seriesUuid = seriesId.toUuidOrNull() ?: return null
val seasonUuid = seasonId.toUuidOrNull() ?: return null
return Episode(
id = episodeId,
seriesId = seriesUuid,
seasonId = seasonUuid,
index = index,
title = title,
synopsis = synopsis,
releaseDate = releaseDate,
rating = rating,
runtime = runtime,
progress = progress,
watched = watched,
format = format,
imageUrlPrefix = imageUrlPrefix,
cast = cast.map { it.toCastMember() },
)
}
private fun CastMember.toCachedCastMember() = CachedCastMember(
name = name,
role = role,
imageUrl = imageUrl,
)
private fun CachedCastMember.toCastMember() = CastMember(
name = name,
role = role,
imageUrl = imageUrl,
)
private fun String.toUuidOrNull(): UUID? = runCatching { UUID.fromString(this) }.getOrNull()