Show each episode's own TMDB score next to the series ratings
All checks were successful
review/code-claude Claude code review: pass, no blocking findings (episode TMDB badge)

This commit is contained in:
justinzeus 2026-10-29 21:35:13 +01:00
parent 2ccce98dc1
commit 85365aa3c6
8 changed files with 428 additions and 10 deletions

View file

@ -157,4 +157,48 @@ class SourceRatingsTest {
assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty())
assertFalse(movie.isEmpty)
}
@Test
fun episodeScoreFollowsThePwaRulesForAUserScore() {
assertEquals(7.9, SourceRatings.episodeScore(7.9, 120)!!, 0.0)
// unrated, never 0
assertNull(SourceRatings.episodeScore(0.0, 0))
assertNull(SourceRatings.episodeScore(8.0, 0))
assertNull(SourceRatings.episodeScore(8.0, null))
// not observed or out of range
assertNull(SourceRatings.episodeScore(null, 10))
assertNull(SourceRatings.episodeScore(10.5, 10))
assertNull(SourceRatings.episodeScore(-1.0, 10))
assertEquals(10.0, SourceRatings.episodeScore(10.0, 1)!!, 0.0)
}
@Test
fun episodeKeepsTheSeriesRottenTomatoesAndHasOneLabelledTmdbNumber() {
val series =
SourceRatings.of(rtCriticsScore = 63, rtCriticsLabel = "Fresh", rtAudienceScore = 71, rtAudienceLabel = "Upright", tmdbScore = 7.6, tmdbVotes = 3000)
// the series only has its TMDB score because it has no other source
val withTmdbOnly = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 3000)
assertEquals(7.6, withTmdbOnly.tmdb!!, 0.0)
val episode = withTmdbOnly.withEpisode(8.2)
assertNull(episode.tmdb)
val entries = episode.visibleEntries(community = true, critic = true)
assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge })
assertEquals(listOf("8.2"), entries.map { it.text })
assertEquals(listOf(true), entries.map { it.isEpisode })
val full = series.withEpisode(8.2).visibleEntries(community = true, critic = true)
assertEquals(listOf("63%", "71%", "8.2"), full.map { it.text })
assertEquals(listOf(false, false, true), full.map { it.isEpisode })
// the display settings treat it as a community (IMDb/TMDB) rating
assertEquals(listOf("8.2"), series.withEpisode(8.2).visibleEntries(community = true, critic = false).map { it.text })
assertEquals(listOf("63%", "71%"), series.withEpisode(8.2).visibleEntries(community = false, critic = true).map { it.text })
}
@Test
fun withoutAnEpisodeScoreTheSeriesRatingsAreUnchanged() {
val series = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 10)
assertEquals(series, series.withEpisode(null))
assertFalse(SourceRatings().withEpisode(5.0).isEmpty)
}
}

View file

@ -7,6 +7,9 @@ import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.TvApi
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails
import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SeerrItemType
@ -265,4 +268,159 @@ class SeerrRatingsItemsTest {
assertEquals(true, cancelled)
assertNull(service.cached(SeerrItemType.MOVIE, 8))
}
private fun season(vararg episodes: Triple<Int, Double?, Long?>) =
SeasonDetails(
seasonNumber = 5,
episodes = episodes.map { (number, average, votes) -> SeasonEpisode(episodeNumber = number, voteAverage = average?.toBigDecimal(), voteCount = votes) },
)
private fun episode(
season: Int?,
episode: Int?,
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.EPISODE, seriesId = seriesId, parentIndexNumber = season, indexNumber = episode))
private fun seriesKnown() {
jellyfinHasSeries()
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings
}
@Test
fun theSeasonIsFetchedOnceForEveryEpisodeOfIt() =
runTest {
seriesKnown()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns
season(Triple(1, 8.24, 120L), Triple(2, 7.0, 80L), Triple(3, 9.5, 10L))
val first = service.forItem(episode(5, 1)).first()!!
val second = service.forItem(episode(5, 2)).first()!!
val third = service.forItem(episode(5, 3)).first()!!
// each episode has its own score next to the series' Rotten Tomatoes
assertEquals(listOf(8.24, 7.0, 9.5), listOf(first, second, third).map { it.episodeTmdb })
assertEquals(80, first.rtCritics?.percent)
coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) }
coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) }
// and another episode of the season is known without waiting
assertEquals(7.0, service.cachedFor(episode(5, 2))!!.episodeTmdb!!, 0.0)
}
@Test
fun eachSeasonIsItsOwnRequest() =
runTest {
seriesKnown()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 1) } returns season(Triple(1, 6.0, 5L))
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 2) } returns season(Triple(1, 9.0, 5L))
assertEquals(6.0, service.forItem(episode(1, 1)).first()!!.episodeTmdb!!, 0.0)
assertEquals(9.0, service.forItem(episode(2, 1)).first()!!.episodeTmdb!!, 0.0)
// the same episode number of another season is not mistaken for it
assertNull(service.cachedFor(episode(3, 1)))
}
@Test
fun unratedEpisodesShowOnlyTheSeriesRatings() =
runTest {
seriesKnown()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns
season(Triple(1, 0.0, 0L), Triple(2, 8.0, null), Triple(3, null, null))
listOf(1, 2, 3, 4).forEach { number ->
val ratings = service.forItem(episode(5, number)).first()!!
assertNull("episode $number", ratings.episodeTmdb)
assertEquals("episode $number", 80, ratings.rtCritics?.percent)
}
}
@Test
fun episodeWithoutSeasonOrEpisodeNumberShowsTheSeriesRatingsWithoutAskingForTheSeason() =
runTest {
seriesKnown()
assertEquals(80, service.forItem(episode(null, 1)).first()!!.rtCritics?.percent)
assertEquals(80, service.forItem(episode(5, null)).first()!!.rtCritics?.percent)
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
}
@Test
fun episodeTmdbReplacesTheSeriesTmdbWhenThatWasTheOnlySource() =
runTest {
jellyfinHasSeries()
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns TvTvIdRatingsGet200Response()
coEvery { tvApi.tvTvIdGet(tvId = seriesTmdb) } returns
mockk {
every { voteAverage } returns 7.6.toBigDecimal()
every { voteCount } returns 900
}
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns season(Triple(1, 8.8, 40L))
val ratings = service.forItem(episode(5, 1)).first()!!
assertNull(ratings.tmdb)
assertEquals(8.8, ratings.episodeTmdb!!, 0.0)
}
@Test
fun failedSeasonRequestKeepsTheSeriesRatingsAndIsRetried() =
runTest {
seriesKnown()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } throws IOException("down") andThen
season(Triple(1, 8.0, 3L))
val first = service.forItem(episode(5, 1)).first()!!
assertNull(first.episodeTmdb)
assertEquals(80, first.rtCritics?.percent)
// not cached as "no score": the screen has to wait again
assertNull(service.cachedFor(episode(5, 1)))
assertEquals(8.0, service.forItem(episode(5, 1)).first()!!.episodeTmdb!!, 0.0)
}
@Test
fun seasonTmdbDoesNotKnowIsAnAnswer() =
runTest {
seriesKnown()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } throws ClientException("none", 404)
assertNull(service.forItem(episode(9, 1)).first()!!.episodeTmdb)
service.forItem(episode(9, 2)).first()
coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) }
}
@Test
fun seriesWithoutATmdbIdShowsNothingExtra() =
runTest {
jellyfinHasSeries(tmdb = null)
assertNull(service.forItem(episode(5, 1)).first())
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
}
@Test
fun seasonsStillShowOnlyTheirSeriesRatings() =
runTest {
seriesKnown()
val season = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.SEASON, seriesId = seriesId, indexNumber = 5, parentIndexNumber = 5))
assertNull(service.forItem(season).first()!!.episodeTmdb)
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
}
@Test
fun scrollingPastEpisodesNeverFetchesASeason() =
runTest {
seriesKnown()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } returns season(Triple(1, 8.0, 3L))
repeat(10) { number ->
val job = launch { service.forItem(episode(5, number + 1)).collect { } }
runCurrent()
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50)
job.cancel()
}
runCurrent()
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
}
}

View file

@ -10,7 +10,8 @@ import com.github.damontecres.wholphin.api.seerr.TvApi
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.SeasonDetails
import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SourceRatings
@ -162,6 +163,47 @@ class SourceRatingsRowTest {
assertJellyfinRatingNotShown()
}
private fun episodeWithScores(): BaseItem {
seriesRatings()
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns
SeasonDetails(episodes = listOf(SeasonEpisode(episodeNumber = 1, voteAverage = 8.1.toBigDecimal(), voteCount = 50)))
return BaseItem(
BaseItemDto(
id = UUID.randomUUID(),
type = BaseItemKind.EPISODE,
providerIds = mapOf("Tmdb" to "331212"),
seriesId = seriesId,
parentIndexNumber = 5,
indexNumber = 1,
communityRating = 7.8f,
criticRating = 59f,
),
)
}
@Test
fun episodeShowsItsOwnLabelledTmdbScoreNextToTheSeriesRottenTomatoes() {
show(episodeWithScores())
assertShown("81%")
assertShown("45%")
assertShown("Ep 8.1")
assertJellyfinRatingNotShown()
}
@Test
fun episodeScoreFollowsTheCommunityRatingSetting() {
show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING))
assertShown("81%")
assertNotShown("Ep 8.1")
}
@Test
fun episodeScoreStaysWhenRottenTomatoesIsHidden() {
show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING))
assertShown("Ep 8.1")
assertNotShown("81%")
}
@Test
fun seasonShowsItsSeriesRatings() {
seriesRatings()
@ -228,12 +270,12 @@ class SourceRatingsRowTest {
imdbVotes = 10,
)
val entries = ratings.visibleEntries(community = true, critic = true)
val text = ratingsText(entries, leadingDot = false)
val text = ratingsText(entries, leadingDot = false, episodeLabel = "Ep")
assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%"))
assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8"))
// dots only between entries, or also in front when something precedes the ratings
assertEquals(2, text.text.split("\u2022").size - 1)
assertEquals(3, ratingsText(entries, leadingDot = true).text.split("\u2022").size - 1)
assertEquals(3, ratingsText(entries, leadingDot = true, episodeLabel = "Ep").text.split("\u2022").size - 1)
}
}