Add source ratings model and Seerr ratings service matching the PWA

This commit is contained in:
justinzeus 2026-10-29 20:00:31 +01:00
parent d78a23a80a
commit e92ade99df
10 changed files with 1032 additions and 40 deletions

View file

@ -0,0 +1,143 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class SourceRatingsTest {
@Test
fun movieShowsRtCriticsRtAudienceAndImdbButNotTmdb() {
val ratings =
SourceRatings.of(
rtCriticsScore = 96,
rtCriticsLabel = "Certified Fresh",
rtAudienceScore = 90,
rtAudienceLabel = "Upright",
imdbScore = 8.8,
imdbVotes = 2_500_000,
tmdbScore = 8.4,
tmdbVotes = 30_000,
)
assertEquals(RtScore(96, RtMark.FRESH), ratings.rtCritics)
assertEquals(RtScore(90, RtMark.UPRIGHT), ratings.rtAudience)
assertEquals(8.8, ratings.imdb!!, 0.0)
assertNull(ratings.tmdb)
}
@Test
fun tmdbIsShownOnlyWhenNothingElseIs() {
val ratings = SourceRatings.of(tmdbScore = 7.633, tmdbVotes = 120)
assertEquals(7.633, ratings.tmdb!!, 0.0)
assertNull(ratings.rtCritics)
assertNull(ratings.imdb)
assertEquals(listOf("7.6"), ratings.communityEntries.map { it.text })
// A single non-TMDB source hides TMDB
assertNull(SourceRatings.of(rtAudienceScore = 40, tmdbScore = 7.0, tmdbVotes = 10).tmdb)
assertNull(SourceRatings.of(imdbScore = 5.0, imdbVotes = 10, tmdbScore = 7.0, tmdbVotes = 10).tmdb)
}
@Test
fun zeroVoteScoresAreUnratedNotZero() {
val ratings = SourceRatings.of(imdbScore = 0.0, imdbVotes = 0, tmdbScore = 0.0, tmdbVotes = 0)
assertTrue(ratings.isEmpty)
// A zero-vote IMDb score doesn't count as a source, so TMDB can still be the fallback
val withTmdb = SourceRatings.of(imdbScore = 6.5, imdbVotes = 0, tmdbScore = 7.1, tmdbVotes = 55)
assertNull(withTmdb.imdb)
assertEquals(7.1, withTmdb.tmdb!!, 0.0)
}
@Test
fun missingVoteCountIsStillObserved() {
assertEquals(8.8, SourceRatings.of(imdbScore = 8.8).imdb!!, 0.0)
}
@Test
fun outOfRangeScoresAreDropped() {
assertTrue(SourceRatings.of(rtCriticsScore = 101, rtAudienceScore = -1).isEmpty)
assertTrue(SourceRatings.of(imdbScore = 10.5, imdbVotes = 5, tmdbScore = -0.1, tmdbVotes = 5).isEmpty)
assertTrue(SourceRatings.of(imdbScore = 7.0, imdbVotes = -1).isEmpty)
}
@Test
fun rtMarkComesFromSeerrLabelNotThePercentage() {
// 95% but labelled Rotten and 10% labelled Fresh: the label wins
assertEquals(RtMark.ROTTEN, SourceRatings.of(rtCriticsScore = 95, rtCriticsLabel = "Rotten").rtCritics!!.mark)
assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 10, rtCriticsLabel = "Fresh").rtCritics!!.mark)
assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 80, rtCriticsLabel = "Certified Fresh").rtCritics!!.mark)
assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 20, rtAudienceLabel = "Upright").rtAudience!!.mark)
assertEquals(RtMark.SPILLED, SourceRatings.of(rtAudienceScore = 90, rtAudienceLabel = "Spilled").rtAudience!!.mark)
}
@Test
fun rtWithoutKnownLabelUsesPwaDefaults() {
assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90).rtCritics!!.mark)
assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90, rtCriticsLabel = "Mystery").rtCritics!!.mark)
assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 90).rtAudience!!.mark)
}
@Test
fun statusLabelMapping() {
assertEquals(RtMark.FRESH, RtMark.fromLabel("Certified Fresh"))
assertEquals(RtMark.FRESH, RtMark.fromLabel("Fresh"))
assertEquals(RtMark.ROTTEN, RtMark.fromLabel("Rotten"))
assertEquals(RtMark.UPRIGHT, RtMark.fromLabel("Upright"))
assertEquals(RtMark.SPILLED, RtMark.fromLabel("Spilled"))
assertNull(RtMark.fromLabel(null))
assertNull(RtMark.fromLabel("fresh"))
}
@Test
fun entriesUseThePwaTextAndBadges() {
val ratings =
SourceRatings.of(
rtCriticsScore = 85,
rtCriticsLabel = "Fresh",
rtAudienceScore = 40,
rtAudienceLabel = "Spilled",
imdbScore = 8.0,
imdbVotes = 1,
)
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId })
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId })
}
private val jellyfinDetails =
QuickDetailsData(
basic = AnnotatedString("2020"),
communityRating = AnnotatedString("7.8 jellyfin"),
criticRating = AnnotatedString("59% jellyfin"),
)
@Test
fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() {
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null))
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings()))
}
@Test
fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() {
val details =
jellyfinDetails.withSourceRatings(
SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10),
)
// COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes
assertTrue(details.communityRating!!.text.endsWith("8.8"))
assertTrue(details.criticRating!!.text.endsWith("96%"))
assertEquals(jellyfinDetails.basic, details.basic)
}
@Test
fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() {
// Seerr has only RT: the Jellyfin star is not shown next to it
val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh"))
assertNull(details.communityRating)
assertTrue(details.criticRating!!.text.endsWith("50%"))
}
}

View file

@ -0,0 +1,232 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.MoviesApi
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.TvApi
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
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.MovieMovieIdRatingscombinedGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.RtMark
import com.github.damontecres.wholphin.data.model.SeerrItemType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
import java.util.UUID
class SeerrRatingsServiceTest {
private val moviesApi = mockk<MoviesApi>()
private val tvApi = mockk<TvApi>()
private val apiClient =
mockk<SeerrApiClient> {
every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi
every { tvApi } returns this@SeerrRatingsServiceTest.tvApi
}
private var activeFlow = flowOf(true)
private val seerrService =
mockk<SeerrService> {
every { api } returns apiClient
every { active } answers { activeFlow }
}
private val service = SeerrRatingsService(seerrService)
private fun movieDetails(
average: Double?,
count: Int?,
) = mockk<MovieDetails> {
every { voteAverage } returns average?.toBigDecimal()
every { voteCount } returns count
}
private fun tvDetails(
average: Double?,
count: Int?,
) = mockk<TvDetails> {
every { voteAverage } returns average?.toBigDecimal()
every { voteCount } returns count
}
private val fullMovieRatings =
MovieMovieIdRatingscombinedGet200Response(
rt =
MovieMovieIdRatingsGet200Response(
criticsScore = 96,
criticsRating = MovieMovieIdRatingsGet200Response.CriticsRating.CERTIFIED_FRESH,
audienceScore = 90,
audienceRating = MovieMovieIdRatingsGet200Response.AudienceRating.UPRIGHT,
),
imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000),
)
@Test
fun movieUsesRatingsCombinedAndDoesNotRequestTmdbWhenOtherSourcesExist() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 10) } returns fullMovieRatings
val ratings = service.get(SeerrItemType.MOVIE, 10)!!
assertEquals(96, ratings.rtCritics?.percent)
assertEquals(RtMark.FRESH, ratings.rtCritics?.mark)
assertEquals(90, ratings.rtAudience?.percent)
assertEquals(8.8, ratings.imdb!!, 0.0)
assertNull(ratings.tmdb)
coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) }
}
@Test
fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() =
runTest {
coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns
TvTvIdRatingsGet200Response(
criticsScore = 70,
criticsRating = TvTvIdRatingsGet200Response.CriticsRating.FRESH,
audienceScore = 30,
audienceRating = TvTvIdRatingsGet200Response.AudienceRating.SPILLED,
)
val ratings = service.get(SeerrItemType.TV, 20)!!
assertEquals(RtMark.FRESH, ratings.rtCritics?.mark)
assertEquals(RtMark.SPILLED, ratings.rtAudience?.mark)
assertNull(ratings.imdb)
}
@Test
fun tmdbIsFetchedOnlyAsFallbackAndZeroVotesAreUnrated() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 1) } returns
MovieMovieIdRatingscombinedGet200Response()
coEvery { moviesApi.movieMovieIdGet(movieId = 1) } returns movieDetails(7.4, 321)
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 2) } returns
MovieMovieIdRatingscombinedGet200Response()
coEvery { moviesApi.movieMovieIdGet(movieId = 2) } returns movieDetails(0.0, 0)
assertEquals(7.4, service.get(SeerrItemType.MOVIE, 1)!!.tmdb!!, 0.001)
assertTrue(service.get(SeerrItemType.MOVIE, 2)!!.isEmpty)
}
@Test
fun knownTmdbVoteAvoidsExtraRequest() =
runTest {
coEvery { tvApi.tvTvIdRatingsGet(tvId = 3) } returns TvTvIdRatingsGet200Response()
val ratings = service.get(SeerrItemType.TV, 3, TmdbVote(8.1, 900))!!
assertEquals(8.1, ratings.tmdb!!, 0.0)
coVerify(exactly = 0) { tvApi.tvTvIdGet(any()) }
}
@Test
fun movieAndSeriesWithTheSameTmdbIdDoNotShareCachedRatings() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings
coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns
TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = TvTvIdRatingsGet200Response.CriticsRating.ROTTEN)
val movie = service.get(SeerrItemType.MOVIE, 603)!!
val tv = service.get(SeerrItemType.TV, 603)!!
assertEquals(96, movie.rtCritics?.percent)
assertEquals(12, tv.rtCritics?.percent)
assertEquals(RtMark.ROTTEN, tv.rtCritics?.mark)
assertEquals(movie, service.cached(SeerrItemType.MOVIE, 603))
assertEquals(tv, service.cached(SeerrItemType.TV, 603))
}
@Test
fun successIsCachedSoSeerrIsAskedOnce() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns fullMovieRatings
service.get(SeerrItemType.MOVIE, 5)
service.get(SeerrItemType.MOVIE, 5)
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) }
}
@Test
fun failuresAreNotCachedAndAreRetried() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } throws IOException("down") andThen fullMovieRatings
assertNull(service.get(SeerrItemType.MOVIE, 7))
assertNull(service.cached(SeerrItemType.MOVIE, 7))
assertNotNull(service.get(SeerrItemType.MOVIE, 7)?.rtCritics)
}
@Test
fun serverErrorIsAFailureButNotFoundIsAnAnswer() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } throws ClientException("boom", 500)
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 9) } throws ClientException("none", 404)
coEvery { moviesApi.movieMovieIdGet(movieId = 9) } returns movieDetails(6.0, 40)
assertNull(service.get(SeerrItemType.MOVIE, 8))
assertNull(service.cached(SeerrItemType.MOVIE, 8))
// 404 from ratings means no RT/IMDb data, TMDB is still available
assertEquals(6.0, service.get(SeerrItemType.MOVIE, 9)!!.tmdb!!, 0.0)
assertNotNull(service.cached(SeerrItemType.MOVIE, 9))
}
@Test
fun failedTmdbFallbackIsNotCachedAsNoRating() =
runTest {
coEvery { tvApi.tvTvIdRatingsGet(tvId = 11) } returns TvTvIdRatingsGet200Response()
coEvery { tvApi.tvTvIdGet(tvId = 11) } throws IOException("down") andThen tvDetails(7.0, 10)
assertNull(service.get(SeerrItemType.TV, 11))
assertEquals(7.0, service.get(SeerrItemType.TV, 11)!!.tmdb!!, 0.0)
}
private fun jellyfinItem(
type: BaseItemKind,
providerIds: Map<String, String>?,
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds))
@Test
fun jellyfinItemResolvesRatingsThroughItsTmdbId() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings
val ratings = service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first()
assertEquals(96, ratings?.rtCritics?.percent)
}
@Test
fun jellyfinItemFallsBackToNullWhenSeerrCannotAnswer() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns fullMovieRatings
coEvery { tvApi.tvTvIdRatingsGet(any()) } throws IOException("down")
// no TMDB id
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
// not a movie/series
assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first())
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
// call fails
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())
// Seerr not configured
activeFlow = flowOf(false)
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first())
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
}
}