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)
All checks were successful
review/code-claude Claude code review: pass, no blocking findings (episode TMDB badge)
This commit is contained in:
parent
2ccce98dc1
commit
85365aa3c6
8 changed files with 428 additions and 10 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue