Tolerate unknown RT labels, expire cached ratings, avoid duplicate collectors and Jellyfin flash
All checks were successful
review/code-claude Reviewed c54acc9c (PASS); review-fix delta re-checked by firstmate

This commit is contained in:
justinzeus 2026-10-29 20:28:51 +01:00
parent c54acc9c1d
commit 04d659b115
5 changed files with 89 additions and 38 deletions

View file

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@ -50,6 +51,7 @@ class SeerrRatingsService
CacheBuilder
.newBuilder()
.maximumSize(CACHE_SIZE)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>()
fun cached(
@ -57,6 +59,26 @@ class SeerrRatingsService
tmdbId: Int,
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
private fun target(item: BaseItem): Pair<SeerrItemType, Int>? {
val type =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES -> SeerrItemType.TV
else -> return null
}
val tmdbId =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull() ?: return null
return type to tmdbId
}
/**
* The already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it
* doesn't show the Jellyfin values first
*/
fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) }
/**
* Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the
* connection is established asynchronously after login.
@ -65,23 +87,10 @@ class SeerrRatingsService
* Callers should keep showing the Jellyfin values for null.
*/
fun forItem(item: BaseItem): Flow<SourceRatings?> {
val type =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES -> SeerrItemType.TV
else -> null
}
val tmdbId =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
return if (type == null || tmdbId == null) {
flowOf(null)
} else {
seerrService.active
.distinctUntilChanged()
.mapLatest { active -> if (active) get(type, tmdbId) else null }
}
val (type, tmdbId) = target(item) ?: return flowOf(null)
return seerrService.active
.distinctUntilChanged()
.mapLatest { active -> if (active) get(type, tmdbId) else null }
}
/**
@ -176,9 +185,9 @@ class SeerrRatingsService
val rt = combined.response
SourceRatings.of(
rtCriticsScore = rt.criticsScore,
rtCriticsLabel = rt.criticsRating?.value,
rtCriticsLabel = rt.criticsRating,
rtAudienceScore = rt.audienceScore,
rtAudienceLabel = rt.audienceRating?.value,
rtAudienceLabel = rt.audienceRating,
tmdbScore = tmdb?.average,
tmdbVotes = tmdb?.count,
)
@ -195,9 +204,9 @@ class SeerrRatingsService
): SourceRatings =
SourceRatings.of(
rtCriticsScore = response.rt?.criticsScore,
rtCriticsLabel = response.rt?.criticsRating?.value,
rtCriticsLabel = response.rt?.criticsRating,
rtAudienceScore = response.rt?.audienceScore,
rtAudienceLabel = response.rt?.audienceRating?.value,
rtAudienceLabel = response.rt?.audienceRating,
imdbScore = response.imdb?.criticsScore,
imdbVotes = response.imdb?.criticsScoreCount,
tmdbScore = tmdb?.average,
@ -218,5 +227,6 @@ class SeerrRatingsService
companion object {
private const val CACHE_SIZE = 300L
private const val CACHE_HOURS = 6L
}
}

View file

@ -103,6 +103,8 @@ class MovieViewModel
return item
}
private var ratingsJob: Job? = null
fun init(): Job =
viewModelScope.launchDefault {
val movie =
@ -127,6 +129,7 @@ class MovieViewModel
chosenStreams = chosenStreams,
trailers = remoteTrailers,
chapters = chapters,
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
)
}
backdropService.submit(movie)
@ -155,11 +158,14 @@ class MovieViewModel
)
}
}
viewModelScope.launchIO {
ratingsService.forItem(movie).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings) }
// init() runs again on resume and the flow never completes, so don't stack collectors
ratingsJob?.cancel()
ratingsJob =
viewModelScope.launchIO {
ratingsService.forItem(movie).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
}
viewModelScope.launchIO {
val results = seerrService.similar(movie).orEmpty()
_state.update {

View file

@ -221,6 +221,7 @@ class SeriesViewModel
seasons = seasons,
episodes = episodes,
trailers = remoteTrailers,
sourceRatings = ratingsService.cachedFor(series),
)
}
@ -256,7 +257,7 @@ class SeriesViewModel
}
viewModelScope.launchIO {
ratingsService.forItem(series).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings) }
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO {

View file

@ -6528,13 +6528,13 @@ paths:
example: 85
criticsRating:
type: string
enum: ['Rotten', 'Fresh', 'Certified Fresh']
example: 'Certified Fresh'
audienceScore:
type: integer
example: 65
audienceRating:
type: string
enum: ['Spilled', 'Upright']
example: 'Upright'
/movie/{movieId}/ratingscombined:
get:
summary: Get RT and IMDB movie ratings combined
@ -6573,13 +6573,13 @@ paths:
example: 85
criticsRating:
type: string
enum: ['Rotten', 'Fresh', 'Certified Fresh']
example: 'Certified Fresh'
audienceScore:
type: integer
example: 65
audienceRating:
type: string
enum: ['Spilled', 'Upright']
example: 'Upright'
imdb:
type: object
properties:
@ -6777,13 +6777,13 @@ paths:
example: 85
criticsRating:
type: string
enum: ['Rotten', 'Fresh', 'Certified Fresh']
example: 'Certified Fresh'
audienceScore:
type: integer
example: 65
audienceRating:
type: string
enum: ['Spilled', 'Upright']
example: 'Upright'
/person/{personId}:
get:
summary: Get person details