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

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import com.github.damontecres.wholphin.api.seerr.MoviesApi
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.TvApi import com.github.damontecres.wholphin.api.seerr.TvApi
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.api.seerr.infrastructure.Serializer
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
@ -67,9 +68,9 @@ class SeerrRatingsServiceTest {
rt = rt =
MovieMovieIdRatingsGet200Response( MovieMovieIdRatingsGet200Response(
criticsScore = 96, criticsScore = 96,
criticsRating = MovieMovieIdRatingsGet200Response.CriticsRating.CERTIFIED_FRESH, criticsRating = "Certified Fresh",
audienceScore = 90, audienceScore = 90,
audienceRating = MovieMovieIdRatingsGet200Response.AudienceRating.UPRIGHT, audienceRating = "Upright",
), ),
imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000), imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000),
) )
@ -89,15 +90,42 @@ class SeerrRatingsServiceTest {
coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) } coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) }
} }
@Test
fun unknownRtLabelsDoNotDropTheResponse() =
runTest {
val json = Serializer.kotlinxSerializationJson
// Labels Seerr might add later must not fail parsing; the scores still show
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 30) } returns
json.decodeFromString<MovieMovieIdRatingscombinedGet200Response>(
"""{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" +
""""imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""",
)
coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns
json.decodeFromString<TvTvIdRatingsGet200Response>(
"""{"criticsScore":70,"criticsRating":"Brand New","audienceScore":60,"audienceRating":"Other"}""",
)
val movie = service.get(SeerrItemType.MOVIE, 30)!!
assertEquals(92, movie.rtCritics?.percent)
assertEquals(RtMark.UNKNOWN, movie.rtCritics?.mark)
assertEquals(RtMark.UPRIGHT, movie.rtAudience?.mark)
assertEquals(8.8, movie.imdb!!, 0.0)
val tv = service.get(SeerrItemType.TV, 31)!!
assertEquals(70, tv.rtCritics?.percent)
assertEquals(RtMark.UNKNOWN, tv.rtCritics?.mark)
assertEquals(60, tv.rtAudience?.percent)
}
@Test @Test
fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() = fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() =
runTest { runTest {
coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns
TvTvIdRatingsGet200Response( TvTvIdRatingsGet200Response(
criticsScore = 70, criticsScore = 70,
criticsRating = TvTvIdRatingsGet200Response.CriticsRating.FRESH, criticsRating = "Fresh",
audienceScore = 30, audienceScore = 30,
audienceRating = TvTvIdRatingsGet200Response.AudienceRating.SPILLED, audienceRating = "Spilled",
) )
val ratings = service.get(SeerrItemType.TV, 20)!! val ratings = service.get(SeerrItemType.TV, 20)!!
@ -137,7 +165,7 @@ class SeerrRatingsServiceTest {
runTest { runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings
coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns
TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = TvTvIdRatingsGet200Response.CriticsRating.ROTTEN) TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = "Rotten")
val movie = service.get(SeerrItemType.MOVIE, 603)!! val movie = service.get(SeerrItemType.MOVIE, 603)!!
val tv = service.get(SeerrItemType.TV, 603)!! val tv = service.get(SeerrItemType.TV, 603)!!
@ -205,9 +233,15 @@ class SeerrRatingsServiceTest {
runTest { runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings
val ratings = service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first() val item = jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))
assertNull(service.cachedFor(item))
val ratings = service.forItem(item).first()
assertEquals(96, ratings?.rtCritics?.percent) assertEquals(96, ratings?.rtCritics?.percent)
// a later screen can start from the cached value instead of the Jellyfin values
assertEquals(ratings, service.cachedFor(item))
assertNull(service.cachedFor(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "42"))))
} }
@Test @Test