diff --git a/core/src/main/java/hu/bbara/purefin/core/data/HomeRepository.kt b/core/src/main/java/hu/bbara/purefin/core/data/HomeRepository.kt index 0b708431..48609765 100644 --- a/core/src/main/java/hu/bbara/purefin/core/data/HomeRepository.kt +++ b/core/src/main/java/hu/bbara/purefin/core/data/HomeRepository.kt @@ -1,6 +1,5 @@ package hu.bbara.purefin.core.data -import hu.bbara.purefin.core.model.MediaUiModel import hu.bbara.purefin.model.Library import hu.bbara.purefin.model.Media import kotlinx.coroutines.flow.StateFlow @@ -14,15 +13,4 @@ interface HomeRepository { val latestLibraryContent: StateFlow>> fun ensureReady() suspend fun refreshHomeData() - - /** - * Fetches the full content (movies or series) of a single library on - * demand. Used by the library detail screen; the home page no longer - * pays for full library content on every refresh. - * - * Returns `null` when the server returned 304 Not Modified for the - * per-library ETag — the caller should keep its previously fetched - * copy. An empty list is a legitimate "the library is empty" result. - */ - suspend fun loadLibraryContent(libraryId: UUID): List? } diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt index 83ca7e6b..0ddcdd40 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/home/AppViewModel.kt @@ -68,17 +68,16 @@ class AppViewModel @Inject constructor( val libraries = homeRepository.libraries.map { libraries -> libraries.map { - // The home page no longer fetches the full library content - // (see InMemoryAppContentRepository.loadLibraries). isEmpty - // therefore reflects the actual server-reported size, not - // the contents of the local media repository. LibraryUiModel( id = it.id, name = it.name, type = it.type, posterUrl = it.posterUrl, size = it.size, - isEmpty = it.size == 0, + isEmpty = when (it.type) { + LibraryKind.MOVIES -> localMediaRepository.movies.value.isEmpty() + LibraryKind.SERIES -> localMediaRepository.series.value.isEmpty() + } ) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) diff --git a/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt b/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt index 1ce6d6c0..84de0796 100644 --- a/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt +++ b/core/src/main/java/hu/bbara/purefin/core/feature/browse/library/LibraryViewModel.kt @@ -6,13 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.MediaMetadataUpdater import hu.bbara.purefin.core.model.MediaUiModel +import hu.bbara.purefin.core.model.MovieUiModel +import hu.bbara.purefin.core.model.SeriesUiModel import hu.bbara.purefin.core.navigation.MovieDto import hu.bbara.purefin.core.navigation.NavigationManager import hu.bbara.purefin.core.navigation.Route import hu.bbara.purefin.core.navigation.SeriesDto +import hu.bbara.purefin.model.LibraryKind import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject @@ -26,35 +31,24 @@ class LibraryViewModel @Inject constructor( private val selectedLibrary = MutableStateFlow(null) - // Local cache of the last-fetched content per library. Used to preserve - // content when re-selecting a library whose ETag matches (so the - // repository's 304 response can be treated as "use the cached copy"). - private val cachedContents = mutableMapOf>() - - private val _contents = MutableStateFlow>(emptyList()) - val contents: StateFlow> = _contents.asStateFlow() + val contents: StateFlow> = combine(selectedLibrary, homeRepository.libraries) { + libraryId, libraries -> + if (libraryId == null) { + return@combine emptyList() + } + val library = libraries.find { it.id == libraryId } ?: return@combine emptyList() + when (library.type) { + LibraryKind.SERIES -> library.series!!.map { series -> + SeriesUiModel(series) + } + LibraryKind.MOVIES -> library.movies!!.map { movie -> + MovieUiModel(movie) + } + } + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) init { viewModelScope.launch { homeRepository.ensureReady() } - viewModelScope.launch { - selectedLibrary.collect { libraryId -> - if (libraryId == null) { - _contents.value = emptyList() - } else { - val fresh = homeRepository.loadLibraryContent(libraryId) - if (fresh != null) { - cachedContents[libraryId] = fresh - _contents.value = fresh - } else { - // ETag 304 — keep the cached copy for this library if - // we have one. On a first-visit 304 (rare, would - // require the library's ETag to be set without any - // prior fetch) the screen briefly shows empty. - _contents.value = cachedContents[libraryId] ?: emptyList() - } - } - } - } } fun onMovieSelected(movieId: UUID) { diff --git a/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinSdkClient.kt b/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinSdkClient.kt deleted file mode 100644 index b83db8f9..00000000 --- a/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinSdkClient.kt +++ /dev/null @@ -1,13 +0,0 @@ -package hu.bbara.purefin.core.jellyfin - -import javax.inject.Qualifier - -/** - * Marks an `OkHttpClient` that is wired into the Jellyfin SDK's `OkHttpFactory`. - * Distinct from the unqualified client used for image loading and media streaming - * because the SDK has its own auth path and the ETag interceptor should not leak - * into non-SDK HTTP calls. - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class JellyfinSdkClient diff --git a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt index e914461a..bc5a209f 100644 --- a/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt +++ b/data/src/main/java/hu/bbara/purefin/data/catalog/InMemoryAppContentRepository.kt @@ -5,9 +5,6 @@ import hu.bbara.purefin.core.concurrency.SingleFlight import hu.bbara.purefin.core.data.HomeRepository import hu.bbara.purefin.core.data.NetworkMonitor import hu.bbara.purefin.core.data.UserSessionRepository -import hu.bbara.purefin.core.model.MediaUiModel -import hu.bbara.purefin.core.model.MovieUiModel -import hu.bbara.purefin.core.model.SeriesUiModel import hu.bbara.purefin.data.converter.toEpisode import hu.bbara.purefin.data.converter.toLibrary import hu.bbara.purefin.data.converter.toMovie @@ -38,14 +35,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind -import timber.log.Timber +import org.jellyfin.sdk.model.api.CollectionType import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.concurrent.atomics.AtomicBoolean import kotlin.concurrent.atomics.ExperimentalAtomicApi +import timber.log.Timber @Singleton class InMemoryAppContentRepository @Inject constructor( @@ -78,17 +75,6 @@ class InMemoryAppContentRepository @Inject constructor( private val latestLibraryContentState = MutableStateFlow>>(emptyMap()) override val latestLibraryContent: StateFlow>> = latestLibraryContentState.asStateFlow() - // dateLastMediaAdded values captured at the end of the last successful - // refresh. Used to short-circuit per-library /Items/Latest calls when - // the timestamp hasn't moved. Persisted via HomeCache. - private val cachedLibraryDateLastMediaAdded = mutableMapOf() - - // dateLastMediaAdded values observed during the CURRENT refresh's - // /UserViews response. Read by loadLatestLibraryContent to decide - // which libraries need a /Items/Latest call, then merged into - // cachedLibraryDateLastMediaAdded and persisted at the end. - private var currentLibraryDateLastMediaAdded: Map = emptyMap() - init { ensureReady() } @@ -142,14 +128,6 @@ class InMemoryAppContentRepository @Inject constructor( private suspend fun loadHomeCache() { Timber.tag(TAG).d("Loading home cache") val cache = homeCacheDataStore.data.first() - // Hydrate the dateLastMediaAdded map from disk before the first - // refresh runs, so the per-library short-circuit has a baseline. - cachedLibraryDateLastMediaAdded.clear() - cache.libraryDateLastMediaAdded.forEach { (key, date) -> - runCatching { UUID.fromString(key) }.getOrNull()?.let { uuid -> - cachedLibraryDateLastMediaAdded[uuid] = date - } - } if (cache.libraries.isNotEmpty()) { val libraries = cache.libraries.mapNotNull { it.toLibrary() } librariesState.value = libraries @@ -192,12 +170,6 @@ class InMemoryAppContentRepository @Inject constructor( val movies = onlineMediaRepository.movies.value val series = onlineMediaRepository.series.value val episodes = onlineMediaRepository.episodes.value - // Persist this refresh's dateLastMediaAdded as the new baseline - // for the next refresh's per-library short-circuit. - cachedLibraryDateLastMediaAdded.clear() - currentLibraryDateLastMediaAdded.forEach { (id, date) -> - cachedLibraryDateLastMediaAdded[id] = date - } val cache = HomeCache( suggestions = suggestionsState.value.map { it.toCachedItem() }, continueWatching = continueWatchingState.value.map { it.toCachedItem() }, @@ -206,7 +178,6 @@ class InMemoryAppContentRepository @Inject constructor( uuid.toString() to items.map { it.toCachedItem() } }.toMap(), libraries = librariesState.value.map { it.toCachedLibrary() }, - libraryDateLastMediaAdded = cachedLibraryDateLastMediaAdded.mapKeys { it.key.toString() }, 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() }, @@ -246,97 +217,55 @@ class InMemoryAppContentRepository @Inject constructor( .getOrElse { error -> handleRefreshFailure(error, "Unable to load libraries") } - - // Build the new library list in a single pass, so collectors of - // `librariesState` only ever see the final value. When /UserViews - // returns 304 we already have the BaseItemDto list in memory, so - // we just rebuild from it. Otherwise we have the new DTOs. - val filledLibraries: List = if (librariesItem == null) { - // ETag 304 on /UserViews — re-fetch the count per library - // against the existing in-memory state. The size fallback - // (`?: library.size`) handles a 304 on /Items too, so a - // 304 on both endpoints leaves Library.size untouched. - librariesState.value.map { library -> - val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size - when (library.type) { - LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count) - LibraryKind.SERIES -> library.copy(series = emptyList(), size = count) - } - } - } else { - // Capture the server-reported "last media added" timestamp - // per library for this refresh. loadLatestLibraryContent - // reads this map to decide which libraries need a - // /Items/Latest call, and persistHomeCache writes it to - // HomeCache as the new "cached" baseline. - currentLibraryDateLastMediaAdded = librariesItem - .mapNotNull { dto -> - dto.dateLastMediaAdded?.let { dto.id to it.toString() } - } - .toMap() - - // Count-only refresh: ask the server for the number of items - // in each library instead of enumerating them. The home - // dashboard only uses Library.size for the library cards; - // the full movies/series lists are fetched on demand by the - // library detail screen via loadLibraryContent, and on the - // home rows by loadSuggestions / loadContinueWatching / - // loadLatestLibraryContent. - librariesItem.mapNotNull { dto -> - val library = dto.toLibrary(serverUrl()) ?: return@mapNotNull null - val count = jellyfinApiClient.getLibraryItemCount(library.id) ?: library.size - when (library.type) { - LibraryKind.MOVIES -> library.copy(movies = emptyList(), size = count) - LibraryKind.SERIES -> library.copy(series = emptyList(), size = count) - } - } + val filteredLibraries = librariesItem.filter { + it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS } + val emptyLibraries = filteredLibraries.mapNotNull { it.toLibrary(serverUrl()) } + librariesState.value = emptyLibraries + + val filledLibraries = emptyLibraries.map { loadLibrary(it) } librariesState.value = filledLibraries + + val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() } + onlineMediaRepository.upsertMovies(movies) + + val series = filledLibraries.filter { it.type == LibraryKind.SERIES }.flatMap { it.series.orEmpty() } + onlineMediaRepository.upsertSeries(series) } - override suspend fun loadLibraryContent(libraryId: UUID): List? = - singleFlight.run("AppContent:loadLibraryContent:$libraryId") { - val library = librariesState.value.find { it.id == libraryId } ?: return@run emptyList() - // ETag hit — return null so the caller can keep its previously - // fetched copy. The library detail viewmodel uses this signal to - // preserve its in-memory cache across re-selections. - val items = jellyfinApiClient.getLibraryContent(libraryId) ?: return@run null - val url = serverUrl() - when (library.type) { - LibraryKind.MOVIES -> items.map { MovieUiModel(it.toMovie(url)) } - LibraryKind.SERIES -> items.map { SeriesUiModel(it.toSeries(url)) } + private suspend fun loadLibrary(library: Library): Library { + val contentItem = runCatching { jellyfinApiClient.getLibraryContent(library.id) } + .getOrElse { error -> + handleRefreshFailure(error, "Unable to load library ${library.id}") } + return when (library.type) { + LibraryKind.MOVIES -> library.copy( + movies = contentItem.map { it.toMovie(serverUrl()) }, + size = contentItem.size, + ) + LibraryKind.SERIES -> library.copy( + series = contentItem.map { it.toSeries(serverUrl()) }, + size = contentItem.size, + ) } + } private suspend fun loadSuggestions() = singleFlight.run("AppContent:loadSuggestions") { - val url = serverUrl() - // Head-first fetch: ask the server for just the first 2 items and - // compare to the cached head. If the head is unchanged, the rest - // of the row is also unchanged (the home screen shows the head - // prominently and only rotates when new content arrives at the - // top), so we can skip the full request entirely. - val headItems = runCatching { jellyfinApiClient.getSuggestionsHead() } - .getOrElse { error -> - handleRefreshFailure(error, "Unable to load suggestions head") - } - if (headItems == null) return@run - if (headMatches(headItems, suggestionsState.value)) return@run - - // Head changed (or first refresh) — fetch the full list. val suggestionsItems = runCatching { jellyfinApiClient.getSuggestions() } .getOrElse { error -> handleRefreshFailure(error, "Unable to load suggestions") } - if (suggestionsItems == null) return@run - + // Upsert full episode details BEFORE publishing the new row, so the + // home viewmodel's `combine` of this flow with the local media + // repository never observes an empty intermediate state. If we + // assigned suggestionsState first, the new episode IDs would be + // unresolvable for a single emission and the Suggestions section + // would briefly drop from the home screen. suggestionsItems.forEach { item -> - when (item.type) { - BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url))) - BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url))) - else -> {} + if (item.type == BaseItemKind.EPISODE) { + onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl()))) } } - suggestionsState.value = suggestionsItems.mapNotNull { item -> when (item.type) { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) @@ -347,34 +276,21 @@ class InMemoryAppContentRepository @Inject constructor( } private suspend fun loadContinueWatching() = singleFlight.run("AppContent:loadContinueWatching") { - val url = serverUrl() - val headItems = runCatching { jellyfinApiClient.getContinueWatchingHead() } - .getOrElse { error -> - handleRefreshFailure(error, "Unable to load continue watching head") - } - if (headItems == null) return@run - if (headMatches(headItems, continueWatchingState.value)) return@run - val continueWatchingItems = runCatching { jellyfinApiClient.getContinueWatching() } .getOrElse { error -> handleRefreshFailure(error, "Unable to load continue watching") } - if (continueWatchingItems == null) return@run - - // Upsert full details BEFORE publishing the new row, so the + // Upsert full episode details BEFORE publishing the new row, so the // home viewmodel's `combine` of this flow with the local media // repository never observes an empty intermediate state. If we - // assigned continueWatchingState first, the new episode/movie IDs - // would be unresolvable for a single emission and the Continue - // Watching section would briefly drop from the home screen. + // assigned continueWatchingState first, the new episode IDs would + // be unresolvable for a single emission and the Continue Watching + // section would briefly drop from the home screen. continueWatchingItems.forEach { item -> - when (item.type) { - BaseItemKind.MOVIE -> onlineMediaRepository.upsertMovies(listOf(item.toMovie(url))) - BaseItemKind.EPISODE -> onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url))) - else -> {} + if (item.type == BaseItemKind.EPISODE) { + onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl()))) } } - continueWatchingState.value = continueWatchingItems.mapNotNull { item -> when (item.type) { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) @@ -385,30 +301,19 @@ class InMemoryAppContentRepository @Inject constructor( } private suspend fun loadNextUp() = singleFlight.run("AppContent:loadNextUp") { - val url = serverUrl() - val headItems = runCatching { jellyfinApiClient.getNextUpHead() } - .getOrElse { error -> - handleRefreshFailure(error, "Unable to load next up head") - } - if (headItems == null) return@run - if (headMatches(headItems, nextUpState.value)) return@run - val nextUpItems = runCatching { jellyfinApiClient.getNextUpEpisodes() } .getOrElse { error -> handleRefreshFailure(error, "Unable to load next up") } - if (nextUpItems == null) return@run - - // Upsert full details BEFORE publishing the new row, so the + // Upsert full episode details BEFORE publishing the new row, so the // home viewmodel's `combine` of this flow with the local media // repository never observes an empty intermediate state. If we // assigned nextUpState first, the new episode IDs would be // unresolvable for a single emission and the Next Up section // would briefly drop from the home screen. nextUpItems.forEach { item -> - onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(url))) + onlineMediaRepository.upsertEpisodes(listOf(item.toEpisode(serverUrl()))) } - nextUpState.value = nextUpItems.map { item -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!) } @@ -420,62 +325,30 @@ class InMemoryAppContentRepository @Inject constructor( val filteredLibraries = librariesState.value val url = serverUrl() val latestLibraryContents = filteredLibraries.associate { library -> - // Layer 1: skip when the server-reported "last media added" - // timestamp is unchanged since the last refresh. This is the - // cheapest check (no request) and handles the common "no new - // content" case. - val cachedDate = cachedLibraryDateLastMediaAdded[library.id] - val currentDate = currentLibraryDateLastMediaAdded[library.id] - if (cachedDate != null && cachedDate == currentDate) { - library.id to (latestLibraryContentState.value[library.id] ?: emptyList()) - } else { - // Layer 2: head-only fetch. If the first 2 items of the - // latest row are the same as the cached head, the rest of - // the row is also unchanged (the home row only rotates at - // the top) and we can skip the full request. - val headDtos = runCatching { jellyfinApiClient.getLatestFromLibraryHead(library.id) } - .getOrElse { error -> - handleRefreshFailure(error, "Unable to load latest head for library ${library.id}") - } - val cachedSlice = latestLibraryContentState.value[library.id].orEmpty() - if (headDtos == null || headMatches(headDtos, cachedSlice)) { - library.id to cachedSlice - } else { - // Layer 3: head changed — fetch the full row. - val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } - .getOrElse { error -> - handleRefreshFailure(error, "Unable to load latest items for library ${library.id}") + val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } + .getOrElse { error -> + handleRefreshFailure(error, "Unable to load latest items for library ${library.id}") + } + library.id to when (library.type) { + LibraryKind.MOVIES -> latestFromLibrary.map { + val movie = it.toMovie(url) + Media.MovieMedia(movieId = movie.id) + } + LibraryKind.SERIES -> latestFromLibrary.map { + when (it.type) { + BaseItemKind.SERIES -> { + val series = it.toSeries(url) + Media.SeriesMedia(seriesId = series.id) } - if (latestFromLibrary == null) { - library.id to cachedSlice - } else { - val media = when (library.type) { - LibraryKind.MOVIES -> latestFromLibrary.map { - val movie = it.toMovie(url) - onlineMediaRepository.upsertMovies(listOf(movie)) - Media.MovieMedia(movieId = movie.id) - } - LibraryKind.SERIES -> latestFromLibrary.map { dto -> - when (dto.type) { - BaseItemKind.SERIES -> { - val series = dto.toSeries(url) - onlineMediaRepository.upsertSeries(listOf(series)) - Media.SeriesMedia(seriesId = series.id) - } - BaseItemKind.SEASON -> { - val season = dto.toSeason() - Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId) - } - BaseItemKind.EPISODE -> { - val episode = dto.toEpisode(url) - onlineMediaRepository.upsertEpisodes(listOf(episode)) - Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId) - } - else -> throw UnsupportedOperationException("Unsupported item type: ${dto.type}") - } - } + BaseItemKind.SEASON -> { + val season = it.toSeason() + Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId) } - library.id to media + BaseItemKind.EPISODE -> { + val episode = it.toEpisode(url) + Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId) + } + else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}") } } } @@ -483,29 +356,6 @@ class InMemoryAppContentRepository @Inject constructor( latestLibraryContentState.value = latestLibraryContents } - /** - * Returns true when the first [JellyfinApiClient.HEAD_LIMIT] IDs of the - * freshly fetched [headDtos] match the first - * [JellyfinApiClient.HEAD_LIMIT] IDs of the cached [cachedMedia]. Used - * by the home row refreshers to short-circuit the full request when - * the head of the row is unchanged. - * - * Order-sensitive: the comparison is positional. The Jellyfin server - * can reorder rows (e.g., Suggestions reorders on a relevance - * recompute even when the item set is unchanged), which will report - * a false "head changed" and pay for the full request. The reverse - * false negative — head order unchanged but a mid-list item rotated — - * is a known limitation of the head-diff heuristic. - */ - private fun headMatches( - headDtos: List, - cachedMedia: List, - ): Boolean { - val headIds = headDtos.take(JellyfinApiClient.HEAD_LIMIT).map { it.id } - val cachedIds = cachedMedia.take(JellyfinApiClient.HEAD_LIMIT).map { it.id } - return headIds == cachedIds - } - private suspend fun serverUrl(): String { return userSessionRepository.serverUrl.first() } @@ -554,4 +404,4 @@ private data class HomeContentSnapshot( val latestLibraryContent: Map>, ) -private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause) \ No newline at end of file +private class HomeRefreshFailedException(cause: Throwable) : RuntimeException(cause) diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt index 17d5b83c..8236d57e 100644 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/client/JellyfinApiClient.kt @@ -8,8 +8,6 @@ import hu.bbara.purefin.core.data.PlaybackMethod import hu.bbara.purefin.core.data.PlaybackReportContext import hu.bbara.purefin.core.data.QuickConnectSession import hu.bbara.purefin.core.data.UserSessionRepository -import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient -import hu.bbara.purefin.data.jellyfin.etag.NotModifiedException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -34,7 +32,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.api.client.extensions.userViewsApi import org.jellyfin.sdk.api.client.extensions.videosApi -import org.jellyfin.sdk.api.okhttp.OkHttpFactory import org.jellyfin.sdk.createJellyfin import org.jellyfin.sdk.discovery.RecommendedServerInfo import org.jellyfin.sdk.discovery.RecommendedServerInfoScore @@ -69,12 +66,10 @@ import javax.inject.Singleton class JellyfinApiClient @Inject constructor( @ApplicationContext private val applicationContext: Context, private val userSessionRepository: UserSessionRepository, - @JellyfinSdkClient private val okHttpFactory: OkHttpFactory, ) { private val jellyfin = createJellyfin { context = applicationContext clientInfo = ClientInfo(name = "Purefin", version = "0.0.1") - apiClientFactory = okHttpFactory } val api = jellyfin.createApi() @@ -219,21 +214,17 @@ class JellyfinApiClient @Inject constructor( } } - suspend fun getLibraries(): List? = withContext(Dispatchers.IO) { + suspend fun getLibraries(): List = withContext(Dispatchers.IO) { logRequest("getLibraries") { if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val response = api.userViewsApi.getUserViews( - userId = getUserId(), - presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS), - includeHidden = false, - ) - response.content.items - } catch (e: NotModifiedException) { - null + return@logRequest emptyList() } + val response = api.userViewsApi.getUserViews( + userId = getUserId(), + presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS), + includeHidden = false, + ) + response.content.items } } @@ -247,243 +238,91 @@ class JellyfinApiClient @Inject constructor( } } - suspend fun getLibraryContent(libraryId: UUID): List? = withContext(Dispatchers.IO) { + suspend fun getLibraryContent(libraryId: UUID): List = withContext(Dispatchers.IO) { logRequest("getLibraryContent") { if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val getItemsRequest = GetItemsRequest( - userId = getUserId(), - enableImages = true, - parentId = libraryId, - fields = defaultItemFields + ItemFields.OVERVIEW, - enableUserData = true, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - recursive = true, - ) - val response = api.itemsApi.getItems(getItemsRequest) - response.content.items - } catch (e: NotModifiedException) { - null + return@logRequest emptyList() } + val getItemsRequest = GetItemsRequest( + userId = getUserId(), + enableImages = true, + parentId = libraryId, + fields = defaultItemFields + ItemFields.OVERVIEW, + enableUserData = true, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + recursive = true, + ) + val response = api.itemsApi.getItems(getItemsRequest) + response.content.items } } - /** - * Returns the total number of items in [libraryId] without enumerating - * them. Uses `getItems` with `limit=0, enableTotalRecordCount=true`, - * which the Jellyfin server short-circuits to a single integer in the - * response. The home page uses this to populate `Library.size` for the - * library cards on the dashboard without paying for the full library - * content. - * - * ETag is honored via the [ETagInterceptor]: `null` means the cached - * count is still current. - */ - suspend fun getLibraryItemCount(libraryId: UUID): Int? = withContext(Dispatchers.IO) { - logRequest("getLibraryItemCount") { - if (!ensureConfigured()) { - return@logRequest 0 - } - try { - val request = GetItemsRequest( - userId = getUserId(), - parentId = libraryId, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - recursive = true, - limit = 0, - enableTotalRecordCount = true, - ) - val response = api.itemsApi.getItems(request) - response.content.totalRecordCount - } catch (e: NotModifiedException) { - null - } - } - } - - suspend fun getSuggestions(): List? = withContext(Dispatchers.IO) { + suspend fun getSuggestions(): List = withContext(Dispatchers.IO) { logRequest("getSuggestions") { if (!ensureConfigured()) { - return@logRequest emptyList() - } - val userId = getUserId() ?: return@logRequest emptyList() - try { - val response = api.suggestionsApi.getSuggestions( - userId = userId, - mediaType = listOf(MediaType.VIDEO), - type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - limit = 8, - enableTotalRecordCount = true, - ) - response.content.items - } catch (e: NotModifiedException) { - null + return@logRequest emptyList() } + val userId = getUserId() ?: return@logRequest emptyList() + val response = api.suggestionsApi.getSuggestions( + userId = userId, + mediaType = listOf(MediaType.VIDEO), + type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), + limit = 8, + enableTotalRecordCount = true, + ) + response.content.items } } - suspend fun getContinueWatching(): List? = withContext(Dispatchers.IO) { + suspend fun getContinueWatching(): List = withContext(Dispatchers.IO) { logRequest("getContinueWatching") { if (!ensureConfigured()) { - return@logRequest emptyList() - } - val userId = getUserId() ?: return@logRequest emptyList() - try { - val getResumeItemsRequest = GetResumeItemsRequest( - userId = userId, - fields = defaultItemFields + ItemFields.OVERVIEW, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), - enableUserData = true, - startIndex = 0, - ) - val response: Response = api.itemsApi.getResumeItems(getResumeItemsRequest) - response.content.items - } catch (e: NotModifiedException) { - null + return@logRequest emptyList() } + val userId = getUserId() ?: return@logRequest emptyList() + val getResumeItemsRequest = GetResumeItemsRequest( + userId = userId, + fields = defaultItemFields + ItemFields.OVERVIEW, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), + enableUserData = true, + startIndex = 0, + ) + val response: Response = api.itemsApi.getResumeItems(getResumeItemsRequest) + response.content.items } } - suspend fun getNextUpEpisodes(): List? = withContext(Dispatchers.IO) { + suspend fun getNextUpEpisodes(): List = withContext(Dispatchers.IO) { logRequest("getNextUpEpisodes") { if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val getNextUpRequest = GetNextUpRequest( - userId = getUserId(), - fields = defaultItemFields + ItemFields.OVERVIEW, - enableResumable = false, - ) - val result = api.tvShowsApi.getNextUp(getNextUpRequest) - result.content.items - } catch (e: NotModifiedException) { - null + throw IllegalStateException("Not configured") } + val getNextUpRequest = GetNextUpRequest( + userId = getUserId(), + fields = defaultItemFields + ItemFields.OVERVIEW, + enableResumable = false, + ) + val result = api.tvShowsApi.getNextUp(getNextUpRequest) + result.content.items } } - suspend fun getLatestFromLibrary(libraryId: UUID): List? = withContext(Dispatchers.IO) { + suspend fun getLatestFromLibrary(libraryId: UUID): List = withContext(Dispatchers.IO) { logRequest("getLatestFromLibrary") { if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val response = api.userLibraryApi.getLatestMedia( - userId = getUserId(), - parentId = libraryId, - fields = defaultItemFields + ItemFields.OVERVIEW, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), - limit = 15, - ) - response.content - } catch (e: NotModifiedException) { - null + return@logRequest emptyList() } + val response = api.userLibraryApi.getLatestMedia( + userId = getUserId(), + parentId = libraryId, + fields = defaultItemFields + ItemFields.OVERVIEW, + includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), + limit = 15, + ) + response.content } } - /** - * Head-only fetch for the suggestions row. Returns the first [limit] - * items so the caller can diff against the cached head and decide - * whether the full request is necessary. ETag is honored via the - * [ETagInterceptor]; `null` means the cached head is still current. - */ - suspend fun getSuggestionsHead(limit: Int = HEAD_LIMIT): List? = withContext(Dispatchers.IO) { - logRequest("getSuggestionsHead") { - if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val response = api.suggestionsApi.getSuggestions( - userId = getUserId(), - mediaType = listOf(MediaType.VIDEO), - type = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), - limit = limit, - enableTotalRecordCount = false, - ) - response.content.items - } catch (e: NotModifiedException) { - null - } - } - } - - /** - * Head-only fetch for the continue-watching row. See [getSuggestionsHead]. - */ - suspend fun getContinueWatchingHead(limit: Int = HEAD_LIMIT): List? = withContext(Dispatchers.IO) { - logRequest("getContinueWatchingHead") { - if (!ensureConfigured()) { - return@logRequest emptyList() - } - val userId = getUserId() ?: return@logRequest emptyList() - try { - val getResumeItemsRequest = GetResumeItemsRequest( - userId = userId, - fields = defaultItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE), - enableUserData = true, - startIndex = 0, - limit = limit, - ) - val response: Response = api.itemsApi.getResumeItems(getResumeItemsRequest) - response.content.items - } catch (e: NotModifiedException) { - null - } - } - } - - /** - * Head-only fetch for the next-up row. See [getSuggestionsHead]. - */ - suspend fun getNextUpHead(limit: Int = HEAD_LIMIT): List? = withContext(Dispatchers.IO) { - logRequest("getNextUpHead") { - if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val getNextUpRequest = GetNextUpRequest( - userId = getUserId(), - fields = defaultItemFields, - enableResumable = false, - limit = limit, - ) - val result = api.tvShowsApi.getNextUp(getNextUpRequest) - result.content.items - } catch (e: NotModifiedException) { - null - } - } - } - - /** - * Head-only fetch for the latest row of a library. See [getSuggestionsHead]. - */ - suspend fun getLatestFromLibraryHead(libraryId: UUID, limit: Int = HEAD_LIMIT): List? = - withContext(Dispatchers.IO) { - logRequest("getLatestFromLibraryHead") { - if (!ensureConfigured()) { - return@logRequest emptyList() - } - try { - val response = api.userLibraryApi.getLatestMedia( - userId = getUserId(), - parentId = libraryId, - fields = defaultItemFields, - includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.SEASON), - limit = limit, - ) - response.content - } catch (e: NotModifiedException) { - null - } - } - } - suspend fun getItemInfo(mediaId: UUID): BaseItemDto? = withContext(Dispatchers.IO) { logRequest("getItemInfo") { if (!ensureConfigured()) { @@ -796,9 +635,5 @@ class JellyfinApiClient @Inject constructor( companion object { private const val TAG = "JellyfinApiClient" - // How many items to fetch on the head-only path. Two is enough to - // detect the common case of "first card unchanged" without making - // the head request meaningfully heavier than the minimum. - const val HEAD_LIMIT = 2 } } diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagCache.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagCache.kt deleted file mode 100644 index 80f8ebf1..00000000 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagCache.kt +++ /dev/null @@ -1,84 +0,0 @@ -package hu.bbara.purefin.data.jellyfin.etag - -import okhttp3.HttpUrl -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -/** - * In-memory store of `ETag` response headers, keyed by full request URL. The - * [ETagInterceptor] reads from this store to add `If-None-Match` to outgoing - * requests and writes to it on every successful response. - * - * ETag handling is scoped to a small set of home-refresh URL patterns - * matched against path segments. All other endpoints (per-item lookups, - * search, sessions, system info, etc.) are passed through unchanged. This - * keeps the 304 → [NotModifiedException] translation from leaking into code - * paths that do not expect it, and prevents search and similar list-shaped - * calls from being treated as cacheable. - * - * The cache is process-scoped: ETags are lost on process death, which means - * the first request after each app start always goes out without - * `If-None-Match`. This is acceptable — the server returns the same data - * with a fresh ETag, and subsequent requests benefit from the cache. - */ -@Singleton -class ETagCache @Inject constructor() { - private val etags = ConcurrentHashMap() - - /** - * Returns `true` if requests to [url] should use ETag-based conditional - * fetching. Only home-refresh endpoints are eligible, and the - * `/Items` list endpoint is only eligible when it is being used as a - * per-library content query (a `parentId` query parameter is present - * and no search-shaped parameters are set). - */ - fun isEligible(url: HttpUrl): Boolean = matchesHomeRefresh(url) - - /** - * Returns the cached ETag for [url], or `null` if none has been stored - * yet. - */ - fun get(url: String): String? = etags[url] - - /** - * Cache [etag] for [url]. The interceptor writes here after every - * eligible 2xx response that carried an ETag header. - */ - fun put(url: String, etag: String) { - etags[url] = etag - } - - private fun matchesHomeRefresh(url: HttpUrl): Boolean { - val segments = url.pathSegments - return when { - // /Users/{userId}/Views — get libraries - segments.size == 3 && segments[0] == "Users" && segments[2] == "Views" -> true - // /Items/Suggestions — get suggestions - segments == listOf("Items", "Suggestions") -> true - // /Users/{userId}/Items/Resume — get resume items (continue watching) - segments.size == 4 && - segments[0] == "Users" && - segments[2] == "Items" && - segments[3] == "Resume" -> true - // /Shows/NextUp — get next up episodes - segments == listOf("Shows", "NextUp") -> true - // /Users/{userId}/Items/Latest — latest items in a library - segments.size == 4 && - segments[0] == "Users" && - segments[2] == "Items" && - segments[3] == "Latest" -> true - // /Items — per-library content list, distinguished from search - // by the presence of `parentId` and the absence of search params. - segments == listOf("Items") -> isPerLibraryItemsQuery(url) - else -> false - } - } - - private fun isPerLibraryItemsQuery(url: HttpUrl): Boolean { - // The per-library content call always sets parentId; the search - // calls set searchTerm or genres but never parentId. Matching - // on parentId alone is enough to keep the ETag scope tight. - return url.queryParameter("parentId") != null - } -} diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagInterceptor.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagInterceptor.kt deleted file mode 100644 index 4b6e018e..00000000 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagInterceptor.kt +++ /dev/null @@ -1,71 +0,0 @@ -package hu.bbara.purefin.data.jellyfin.etag - -import okhttp3.Interceptor -import okhttp3.Response -import timber.log.Timber -import javax.inject.Inject -import javax.inject.Singleton - -/** - * OkHttp interceptor that turns Jellyfin's ETag support into a - * "not modified" signal the SDK caller can act on. - * - * For URLs whose path is in [ETagCache.isEligible]: - * - If a cached ETag exists, the request is sent with `If-None-Match`. - * - If the server replies 304, the response is closed and a - * [NotModifiedException] is thrown so the caller can keep using its - * existing copy of the data. - * - If the server replies 2xx with an ETag header, the new ETag is cached. - * - * All other URLs are passed through untouched. This bounds the surface - * area of the 304 handling: only code paths that explicitly know about - * the home-refresh contract need to catch [NotModifiedException]. - */ -@Singleton -class ETagInterceptor @Inject constructor( - private val cache: ETagCache, -) : Interceptor { - override fun intercept(chain: Interceptor.Chain): Response { - val request = chain.request() - - if (!cache.isEligible(request.url)) { - return chain.proceed(request) - } - - val url = request.url.toString() - val etag = cache.get(url) - val outgoing = if (etag != null) { - request.newBuilder().header(HEADER_IF_NONE_MATCH, etag).build() - } else { - request - } - - val response = chain.proceed(outgoing) - - if (response.code == HTTP_NOT_MODIFIED) { - // Close before throwing so OkHttp does not warn about leaked - // responses. The caller will treat this as "use the cached copy". - response.close() - Timber.tag(TAG).d("ETag hit for %s", url) - throw NotModifiedException() - } - - // Cache the ETag for next time. Only successful responses carry - // meaningful ETags; error responses may carry caching hints but - // we should not treat a 5xx body as a valid cache key. - if (response.isSuccessful) { - response.header(HEADER_ETAG)?.let { newEtag -> - cache.put(url, newEtag) - } - } - - return response - } - - private companion object { - const val TAG = "ETagInterceptor" - const val HEADER_ETAG = "ETag" - const val HEADER_IF_NONE_MATCH = "If-None-Match" - const val HTTP_NOT_MODIFIED = 304 - } -} diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/JellyfinOkHttpModule.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/JellyfinOkHttpModule.kt deleted file mode 100644 index eae7dc54..00000000 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/JellyfinOkHttpModule.kt +++ /dev/null @@ -1,37 +0,0 @@ -package hu.bbara.purefin.data.jellyfin.etag - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import hu.bbara.purefin.core.jellyfin.JellyfinSdkClient -import okhttp3.OkHttpClient -import org.jellyfin.sdk.api.okhttp.OkHttpFactory -import javax.inject.Singleton - -/** - * Provides the [OkHttpClient] (with [ETagInterceptor] installed) and the - * [OkHttpFactory] used by the Jellyfin SDK. The client is qualified with - * [JellyfinSdkClient] so it is distinct from the unqualified OkHttpClient - * in `core.jellyfin.JellyfinNetworkModule`, which is used for image and - * media streaming. The Jellyfin SDK provides its own auth header on every - * request, so the SDK-bound client does not need `JellyfinAuthInterceptor`. - */ -@Module -@InstallIn(SingletonComponent::class) -object JellyfinOkHttpModule { - - @Provides - @Singleton - @JellyfinSdkClient - fun provideJellyfinSdkOkHttpClient(etagInterceptor: ETagInterceptor): OkHttpClient = - OkHttpClient.Builder() - .addInterceptor(etagInterceptor) - .build() - - @Provides - @Singleton - @JellyfinSdkClient - fun provideJellyfinOkHttpFactory(@JellyfinSdkClient client: OkHttpClient): OkHttpFactory = - OkHttpFactory(base = client) -} diff --git a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/NotModifiedException.kt b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/NotModifiedException.kt deleted file mode 100644 index 6bc77308..00000000 --- a/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/NotModifiedException.kt +++ /dev/null @@ -1,12 +0,0 @@ -package hu.bbara.purefin.data.jellyfin.etag - -/** - * Thrown by [ETagInterceptor] when the Jellyfin server returns 304 Not Modified - * for a request that had an `If-None-Match` header. Callers should treat this - * as "the cached copy is still valid" and avoid re-processing the response. - * - * This is a control-flow signal, not an error — it propagates out of - * `OkHttpClient.await()` before the SDK can deserialize a 304 body. Only - * URLs that have been registered in [ETagCache] can produce this exception. - */ -class NotModifiedException : RuntimeException("ETag match — server returned 304 Not Modified") diff --git a/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt b/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt index 1c15dfc7..e1d3d4cb 100644 --- a/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt +++ b/data/src/main/java/hu/bbara/purefin/data/offline/cache/HomeCache.kt @@ -91,7 +91,6 @@ data class HomeCache( val nextUp: List = emptyList(), val latestLibraryContent: Map> = emptyMap(), val libraries: List = emptyList(), - val libraryDateLastMediaAdded: Map = emptyMap(), val movies: List = emptyList(), val series: List = emptyList(), val episodes: List = emptyList()