mirror of
https://github.com/bbara04/Purefin.git
synced 2026-07-22 17:41:39 +00:00
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.
This commit is contained in:
@@ -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
|
||||
@@ -73,6 +73,17 @@ class InMemoryAppContentRepository @Inject constructor(
|
||||
private val latestLibraryContentState = MutableStateFlow<Map<UUID, List<Media>>>(emptyMap())
|
||||
override val latestLibraryContent: StateFlow<Map<UUID, List<Media>>> = 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<UUID, String>()
|
||||
|
||||
// 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<UUID, String> = 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<Library> = 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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getLibraries(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraries") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
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<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getLibraryContent(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLibraryContent") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
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<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getSuggestions(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getSuggestions") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
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<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getContinueWatching(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getContinueWatching") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
val userId = getUserId() ?: return@logRequest emptyList<BaseItemDto>()
|
||||
try {
|
||||
val getResumeItemsRequest = GetResumeItemsRequest(
|
||||
userId = userId,
|
||||
fields = defaultItemFields + ItemFields.OVERVIEW,
|
||||
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE),
|
||||
enableUserData = true,
|
||||
startIndex = 0,
|
||||
)
|
||||
val response: Response<BaseItemDtoQueryResult> = 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<BaseItemDtoQueryResult> = api.itemsApi.getResumeItems(getResumeItemsRequest)
|
||||
response.content.items
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getNextUpEpisodes(): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getNextUpEpisodes") {
|
||||
if (!ensureConfigured()) {
|
||||
throw IllegalStateException("Not configured")
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
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<BaseItemDto> = withContext(Dispatchers.IO) {
|
||||
suspend fun getLatestFromLibrary(libraryId: UUID): List<BaseItemDto>? = withContext(Dispatchers.IO) {
|
||||
logRequest("getLatestFromLibrary") {
|
||||
if (!ensureConfigured()) {
|
||||
return@logRequest emptyList()
|
||||
return@logRequest emptyList<BaseItemDto>()
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String, String>()
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
@@ -91,6 +91,7 @@ data class HomeCache(
|
||||
val nextUp: List<CachedMediaItem> = emptyList(),
|
||||
val latestLibraryContent: Map<String, List<CachedMediaItem>> = emptyMap(),
|
||||
val libraries: List<CachedLibrary> = emptyList(),
|
||||
val libraryDateLastMediaAdded: Map<String, String> = emptyMap(),
|
||||
val movies: List<CachedMovie> = emptyList(),
|
||||
val series: List<CachedSeries> = emptyList(),
|
||||
val episodes: List<CachedEpisode> = emptyList()
|
||||
|
||||
Reference in New Issue
Block a user