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.Media
import java.util.UUID
import kotlinx.coroutines.flow.StateFlow
import java.util.UUID
interface HomeRepository {
val libraries: StateFlow<List<Library>>
@@ -11,6 +11,6 @@ interface HomeRepository {
val continueWatching: StateFlow<List<Media>>
val nextUp: StateFlow<List<Media>>
val latestLibraryContent: StateFlow<Map<UUID, List<Media>>>
suspend fun ensureReady()
suspend fun refreshHomeData()
fun ensureReady()
fun refreshHomeData()
}

View File

@@ -4,12 +4,9 @@ import android.util.Log
import androidx.datastore.core.DataStore
import hu.bbara.purefin.core.data.HomeRepository
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.image.ArtworkKind
import hu.bbara.purefin.core.image.ImageUrlBuilder
import hu.bbara.purefin.core.model.Episode
import hu.bbara.purefin.core.model.Library
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.Season
import hu.bbara.purefin.core.model.Series
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 hu.bbara.purefin.data.jellyfin.client.JellyfinApiClient
import hu.bbara.purefin.data.offline.cache.HomeCache
import hu.bbara.purefin.data.offline.cache.toCachedEpisode
import hu.bbara.purefin.data.offline.cache.toCachedItem
import hu.bbara.purefin.data.offline.cache.toCachedLibrary
import hu.bbara.purefin.data.offline.cache.toCachedMovie
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.Dispatchers
import kotlinx.coroutines.Job
@@ -33,11 +35,18 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
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.BaseItemKind
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
class InMemoryAppContentRepository @Inject constructor(
@@ -48,10 +57,10 @@ class InMemoryAppContentRepository @Inject constructor(
private val networkMonitor: NetworkMonitor,
) : HomeRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val ensureReadyMutex = Mutex()
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())
override val libraries: StateFlow<List<Library>> = librariesState.asStateFlow()
@@ -74,27 +83,24 @@ class InMemoryAppContentRepository @Inject constructor(
}
}
override suspend fun ensureReady() {
ensureReadyMutex.withLock {
hydrateCacheIfNeeded()
if (initialized) {
return
}
initialized = true
@OptIn(ExperimentalAtomicApi::class)
override fun ensureReady() {
if (!initialized.compareAndSet(expectedValue = false, newValue = true)) {
return
}
refreshHomeData()
Log.d(TAG, "Initializing home repository")
scope.launch { loadHomeCache() }
scope.launch { refreshHomeData() }
}
override suspend fun refreshHomeData() {
ensureReadyMutex.withLock {
hydrateCacheIfNeeded()
}
@Synchronized
override fun refreshHomeData() {
if (refreshJob?.isActive == true) {
refreshJob?.join()
return
}
val job = scope.launch {
runCatching {
Log.d(TAG, "Refreshing home data")
if (!networkMonitor.isOnline.first()) {
return@runCatching
}
@@ -103,23 +109,28 @@ class InMemoryAppContentRepository @Inject constructor(
loadContinueWatching()
loadNextUp()
loadLatestLibraryContent()
Log.d(TAG, "Home refresh successful")
persistHomeCache()
}.onFailure { error ->
Log.w(TAG, "Home refresh failed; keeping cached content", error)
}
}
refreshJob = job
job.join()
}
private suspend fun hydrateCacheIfNeeded() {
if (cacheHydrated) return
loadFromCache()
cacheHydrated = true
}
private suspend fun loadFromCache() {
private suspend fun loadHomeCache() {
Log.d(TAG, "Loading home cache")
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()) {
suggestionsState.value = cache.suggestions.mapNotNull { it.toMedia() }
}
@@ -135,9 +146,23 @@ class InMemoryAppContentRepository @Inject constructor(
uuid to items.mapNotNull { it.toMedia() }
}.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() {
val referencedMediaIds = collectReferencedMediaIds()
val movies = onlineMediaRepository.movies.value
val series = onlineMediaRepository.series.value
val episodes = onlineMediaRepository.episodes.value
val cache = HomeCache(
suggestions = suggestionsState.value.map { it.toCachedItem() },
continueWatching = continueWatchingState.value.map { it.toCachedItem() },
@@ -145,27 +170,39 @@ class InMemoryAppContentRepository @Inject constructor(
latestLibraryContent = latestLibraryContentState.value.map { (uuid, items) ->
uuid.toString() to items.map { it.toCachedItem() }
}.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 }
}
private 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())
}
private 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
private fun collectReferencedMediaIds(): ReferencedHomeMediaIds {
val movieIds = mutableSetOf<UUID>()
val seriesIds = mutableSetOf<UUID>()
val episodeIds = mutableSetOf<UUID>()
val referencedMedia = buildList {
addAll(suggestionsState.value)
addAll(continueWatchingState.value)
addAll(nextUpState.value)
latestLibraryContentState.value.values.forEach { addAll(it) }
}
referencedMedia.forEach { media ->
when (media) {
is Media.MovieMedia -> movieIds += media.movieId
is Media.SeriesMedia -> seriesIds += media.seriesId
is Media.SeasonMedia -> seriesIds += media.seriesId
is Media.EpisodeMedia -> episodeIds += media.episodeId
}
}
return ReferencedHomeMediaIds(
movieIds = movieIds,
seriesIds = seriesIds,
episodeIds = episodeIds,
)
}
suspend fun loadLibraries() {
@@ -419,3 +456,9 @@ class InMemoryAppContentRepository @Inject constructor(
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
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 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
data class CachedMediaItem(
@@ -9,10 +73,193 @@ data class CachedMediaItem(
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
data class HomeCache(
val suggestions: List<CachedMediaItem> = emptyList(),
val continueWatching: 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()