From 5491c381a392ff7b30c73e049f6c724450e594f1 Mon Sep 17 00:00:00 2001 From: Barnabas Balogh Date: Fri, 19 Jun 2026 13:16:07 +0000 Subject: [PATCH] feat(jellyfin): add ETag-based conditional HTTP caching for home refresh Introduce an OkHttp interceptor that caches ETags from home-refresh endpoints and throws NotModifiedException on 304, allowing callers to reuse cached data. Wire it into the Jellyfin SDK via a dedicated @JellyfinSdkClient OkHttpClient. Update API methods to return null on 304, and update InMemoryAppContentRepository to keep existing state when null is returned. Persist dateLastMediaAdded from /UserViews in HomeCache to short-circuit unchanged /Items/Latest calls. --- .../core/jellyfin/JellyfinSdkClient.kt | 13 ++ .../catalog/InMemoryAppContentRepository.kt | 134 ++++++++++++--- .../data/jellyfin/client/JellyfinApiClient.kt | 155 +++++++++++------- .../purefin/data/jellyfin/etag/ETagCache.kt | 84 ++++++++++ .../data/jellyfin/etag/ETagInterceptor.kt | 71 ++++++++ .../jellyfin/etag/JellyfinOkHttpModule.kt | 37 +++++ .../jellyfin/etag/NotModifiedException.kt | 12 ++ .../purefin/data/offline/cache/HomeCache.kt | 1 + 8 files changed, 417 insertions(+), 90 deletions(-) create mode 100644 core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinSdkClient.kt create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagCache.kt create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagInterceptor.kt create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/JellyfinOkHttpModule.kt create mode 100644 data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/NotModifiedException.kt 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 new file mode 100644 index 00000000..b83db8f9 --- /dev/null +++ b/core/src/main/java/hu/bbara/purefin/core/jellyfin/JellyfinSdkClient.kt @@ -0,0 +1,13 @@ +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 333a21e6..ad7b59f2 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 @@ -73,6 +73,17 @@ 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() } @@ -126,6 +137,14 @@ 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 @@ -168,6 +187,12 @@ 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() }, @@ -176,6 +201,7 @@ 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() }, @@ -215,13 +241,33 @@ class InMemoryAppContentRepository @Inject constructor( .getOrElse { error -> handleRefreshFailure(error, "Unable to load libraries") } - val filteredLibraries = librariesItem.filter { - it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS + // Decide which libraries to refresh content for. When the libraries + // endpoint returns 304 (ETag hit), the library list hasn't changed, + // but per-library content is a separate query with its own ETag, so + // we still need to call getLibraryContent for each library to pick + // up content additions that the libraries endpoint doesn't surface. + val librariesToProcess: List = if (librariesItem == null) { + librariesState.value + } else { + val filtered = librariesItem.filter { + it.collectionType == CollectionType.MOVIES || it.collectionType == CollectionType.TVSHOWS + } + // 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 for the next refresh. + currentLibraryDateLastMediaAdded = filtered + .mapNotNull { dto -> + dto.dateLastMediaAdded?.let { dto.id to it.toString() } + } + .toMap() + val emptyLibraries = filtered.map { it.toLibrary(serverUrl()) } + librariesState.value = emptyLibraries + emptyLibraries } - val emptyLibraries = filteredLibraries.map { it.toLibrary(serverUrl()) } - librariesState.value = emptyLibraries - val filledLibraries = emptyLibraries.map { loadLibrary(it) } + val filledLibraries = librariesToProcess.map { loadLibrary(it) } librariesState.value = filledLibraries val movies = filledLibraries.filter { it.type == LibraryKind.MOVIES }.flatMap { it.movies.orEmpty() } @@ -236,6 +282,11 @@ class InMemoryAppContentRepository @Inject constructor( .getOrElse { error -> handleRefreshFailure(error, "Unable to load library ${library.id}") } + // ETag hit — the library's content is unchanged. Return the original + // library so the existing movies/series/size stay in state. + if (contentItem == null) { + return library + } return when (library.type) { LibraryKind.MOVIES -> library.copy( movies = contentItem.map { it.toMovie(serverUrl()) }, @@ -253,6 +304,10 @@ class InMemoryAppContentRepository @Inject constructor( .getOrElse { error -> handleRefreshFailure(error, "Unable to load suggestions") } + // ETag hit — suggestions are unchanged, keep the existing state. + if (suggestionsItems == null) { + return + } suggestionsState.value = suggestionsItems.mapNotNull { item -> when (item.type) { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) @@ -273,6 +328,10 @@ class InMemoryAppContentRepository @Inject constructor( .getOrElse { error -> handleRefreshFailure(error, "Unable to load continue watching") } + // ETag hit — continue watching is unchanged, keep the existing state. + if (continueWatchingItems == null) { + return + } continueWatchingState.value = continueWatchingItems.mapNotNull { item -> when (item.type) { BaseItemKind.MOVIE -> Media.MovieMedia(movieId = item.id) @@ -293,6 +352,10 @@ class InMemoryAppContentRepository @Inject constructor( .getOrElse { error -> handleRefreshFailure(error, "Unable to load next up") } + // ETag hit — next up is unchanged, keep the existing state. + if (nextUpItems == null) { + return + } nextUpState.value = nextUpItems.map { item -> Media.EpisodeMedia(episodeId = item.id, seriesId = item.seriesId!!) } @@ -308,30 +371,47 @@ class InMemoryAppContentRepository @Inject constructor( val filteredLibraries = librariesState.value val url = serverUrl() val latestLibraryContents = filteredLibraries.associate { library -> - 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) + // Skip the per-library GET when the server-reported "last media added" + // timestamp is identical to the one we saw last refresh. This is + // Jellyfin's stable signal that no new content was added, and it lets + // us avoid one request per library on the common case. ETag on + // /Items/Latest remains the fallback for changes that don't bump the + // timestamp (e.g. metadata refreshes that don't add media). + val cachedDate = cachedLibraryDateLastMediaAdded[library.id] + val currentDate = currentLibraryDateLastMediaAdded[library.id] + if (cachedDate != null && cachedDate == currentDate) { + library.id to (latestLibraryContentState.value[library.id] ?: emptyList()) + } else { + val latestFromLibrary = runCatching { jellyfinApiClient.getLatestFromLibrary(library.id) } + .getOrElse { error -> + handleRefreshFailure(error, "Unable to load latest items for library ${library.id}") + } + // ETag hit — /Items/Latest is unchanged, keep the previous slice. + if (latestFromLibrary == null) { + library.id to (latestLibraryContentState.value[library.id] ?: emptyList()) + } else { + library.id to when (library.type) { + LibraryKind.MOVIES -> latestFromLibrary.map { + val movie = it.toMovie(url) + Media.MovieMedia(movieId = movie.id) } - BaseItemKind.SEASON -> { - val season = it.toSeason() - Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId) + LibraryKind.SERIES -> latestFromLibrary.map { + when (it.type) { + BaseItemKind.SERIES -> { + val series = it.toSeries(url) + Media.SeriesMedia(seriesId = series.id) + } + BaseItemKind.SEASON -> { + val season = it.toSeason() + Media.SeasonMedia(seasonId = season.id, seriesId = season.seriesId) + } + BaseItemKind.EPISODE -> { + val episode = it.toEpisode(url) + Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId) + } + else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}") + } } - BaseItemKind.EPISODE -> { - val episode = it.toEpisode(url) - Media.EpisodeMedia(episodeId = episode.id, seriesId = episode.seriesId) - } - else -> throw UnsupportedOperationException("Unsupported item type: ${it.type}") } } } 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 3a5a4d94..39f3259c 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,6 +8,8 @@ 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 @@ -32,6 +34,7 @@ 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 @@ -66,10 +69,12 @@ 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 } private val api = jellyfin.createApi() @@ -214,17 +219,21 @@ 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() + 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 } - val response = api.userViewsApi.getUserViews( - userId = getUserId(), - presetViews = listOf(CollectionType.MOVIES, CollectionType.TVSHOWS), - includeHidden = false, - ) - response.content.items } } @@ -238,88 +247,108 @@ 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() + 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 } - 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 } } - suspend fun getSuggestions(): List = withContext(Dispatchers.IO) { + suspend fun getSuggestions(): List? = withContext(Dispatchers.IO) { logRequest("getSuggestions") { if (!ensureConfigured()) { - return@logRequest emptyList() + 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 } - 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() + 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 } - 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()) { - throw IllegalStateException("Not configured") + 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 } - 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() + 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 } - 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 } } 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 new file mode 100644 index 00000000..80f8ebf1 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagCache.kt @@ -0,0 +1,84 @@ +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 new file mode 100644 index 00000000..4b6e018e --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/ETagInterceptor.kt @@ -0,0 +1,71 @@ +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 new file mode 100644 index 00000000..eae7dc54 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/JellyfinOkHttpModule.kt @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..6bc77308 --- /dev/null +++ b/data/src/main/java/hu/bbara/purefin/data/jellyfin/etag/NotModifiedException.kt @@ -0,0 +1,12 @@ +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 e1d3d4cb..1c15dfc7 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,6 +91,7 @@ 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()