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

@ -61,6 +61,11 @@ sealed interface RatingEntry {
val text: String val text: String
val badge: RatingBadge val badge: RatingBadge
/**
* An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series
*/
val isEpisode: Boolean get() = false
data class RottenTomatoes( data class RottenTomatoes(
val score: RtScore, val score: RtScore,
/** Critics (Tomatometer) or audience (Popcornmeter) */ /** Critics (Tomatometer) or audience (Popcornmeter) */
@ -91,6 +96,14 @@ sealed interface RatingEntry {
override val text: String get() = formatScore(score) override val text: String get() = formatScore(score)
override val badge: RatingBadge get() = RatingBadge.TMDB override val badge: RatingBadge get() = RatingBadge.TMDB
} }
data class EpisodeTmdb(
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val badge: RatingBadge get() = RatingBadge.TMDB
override val isEpisode: Boolean get() = true
}
} }
private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
@ -107,8 +120,10 @@ data class SourceRatings(
val rtAudience: RtScore? = null, val rtAudience: RtScore? = null,
val imdb: Double? = null, val imdb: Double? = null,
val tmdb: Double? = null, val tmdb: Double? = null,
/** The TMDB user score of the episode itself, for episodes */
val episodeTmdb: Double? = null,
) { ) {
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null
/** /**
* Rotten Tomatoes entries in PWA order: critics then audience * Rotten Tomatoes entries in PWA order: critics then audience
@ -128,8 +143,17 @@ data class SourceRatings(
listOfNotNull( listOfNotNull(
imdb?.let { RatingEntry.Imdb(it) }, imdb?.let { RatingEntry.Imdb(it) },
tmdb?.let { RatingEntry.Tmdb(it) }, tmdb?.let { RatingEntry.Tmdb(it) },
episodeTmdb?.let { RatingEntry.EpisodeTmdb(it) },
) )
/**
* These ratings of a series with the TMDB score of one of its episodes. Rotten Tomatoes stays for context, but the
* series' own TMDB score is dropped so there is a single TMDB number, which is the episode's.
*
* @param episodeScore from [episodeScore], if null these ratings are unchanged
*/
fun withEpisode(episodeScore: Double?): SourceRatings = if (episodeScore == null) this else copy(tmdb = null, episodeTmdb = episodeScore)
/** /**
* The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB). * The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB).
* *
@ -144,6 +168,15 @@ data class SourceRatings(
const val MAX_PERCENT = 100.0 const val MAX_PERCENT = 100.0
const val MAX_SCORE = 10.0 const val MAX_SCORE = 10.0
/**
* An episode's TMDB user score by the PWA's rules for a user score: only if observed, within 0-10, and with at least one vote
* (no votes or no vote count is unrated, never 0)
*/
fun episodeScore(
score: Number?,
votes: Long?,
): Double? = if (votes == null) null else userScore(score, votes)
/** /**
* Builds ratings using the PWA's rules: * Builds ratings using the PWA's rules:
* - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped * - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped

View file

@ -49,7 +49,9 @@ data class RatingsTarget(
* Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else: * Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else:
* - movies show their own ratings * - movies show their own ratings
* - series show their own ratings * - series show their own ratings
* - seasons and episodes show their series' ratings, as the PWA does for the title page an episode belongs to * - seasons show their series' ratings, as the PWA does for the title page a season belongs to
* - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season
* Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it.
* - everything else (collections, music, photos, programs, ...) has none * - everything else (collections, music, photos, programs, ...) has none
* *
* Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from
@ -77,6 +79,21 @@ class SeerrRatingsService
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>() .build<Key, SourceRatings>()
private data class SeasonKey(
val tmdbId: Int,
val season: Int,
)
/**
* The TMDB vote of each episode (by episode number) of a series' season
*/
private val seasonCache =
CacheBuilder
.newBuilder()
.maximumSize(CACHE_SIZE)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<SeasonKey, Map<Int, TmdbVote>>()
/** /**
* The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page * The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page
* don't carry their series' provider ids, so this is how they are found. * don't carry their series' provider ids, so this is how they are found.
@ -159,7 +176,18 @@ class SeerrRatingsService
/** /**
* The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty
*/ */
fun cachedFor(item: BaseItem): SourceRatings? = knownTarget(item)?.let { cached(it.type, it.tmdbId) } fun cachedFor(item: BaseItem): SourceRatings? {
val target = knownTarget(item) ?: return null
val series = cached(target.type, target.tmdbId) ?: return null
if (item.type != BaseItemKind.EPISODE) return series
val season = item.data.parentIndexNumber
val episode = item.data.indexNumber
if (season == null || episode == null) return series
val votes = seasonCache.getIfPresent(SeasonKey(target.tmdbId, season)) ?: return null
return series.withEpisode(episodeScore(votes[episode]))
}
private fun episodeScore(vote: TmdbVote?) = vote?.let { SourceRatings.episodeScore(it.average, it.count) }
/** /**
* Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established * Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established
@ -185,12 +213,64 @@ class SeerrRatingsService
seerrService.active seerrService.active
.distinctUntilChanged() .distinctUntilChanged()
.mapLatest { active -> .mapLatest { active ->
if (active) resolveTarget(item)?.let { get(it.type, it.tmdbId) } else null if (active) resolveTarget(item)?.let { getFor(item, it) } else null
}, },
) )
} }
} }
/**
* The ratings for the target of an item: for an episode, those of its series with its own TMDB score
*/
private suspend fun getFor(
item: BaseItem,
target: RatingsTarget,
): SourceRatings? {
val series = get(target.type, target.tmdbId)
val season = item.data.parentIndexNumber
val episode = item.data.indexNumber
if (item.type != BaseItemKind.EPISODE || season == null || episode == null) return series
val votes = getSeason(target.tmdbId, season)
if (series == null && votes == null) return null
return (series ?: SourceRatings()).withEpisode(episodeScore(votes?.get(episode)))
}
/**
* The TMDB votes of the episodes of a season, fetched once for all of them. Null if the request failed, which is not cached.
*/
private suspend fun getSeason(
tmdbId: Int,
season: Int,
): Map<Int, TmdbVote>? {
val key = SeasonKey(tmdbId, season)
seasonCache.getIfPresent(key)?.let { return it }
val votes =
try {
seerrService.api.tvApi
.tvTvIdSeasonSeasonNumberGet(tvId = tmdbId, seasonNumber = season)
.episodes
.orEmpty()
.mapNotNull { episode ->
episode.episodeNumber?.let { it to TmdbVote(episode.voteAverage?.toDouble(), episode.voteCount) }
}.toMap()
} catch (ex: CancellationException) {
throw ex
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
// TMDB has no such season, that is an answer rather than a failure
emptyMap()
} else {
Timber.w(ex, "Error fetching season %s of %s", season, tmdbId)
return null
}
} catch (ex: Exception) {
Timber.w(ex, "Error fetching season %s of %s", season, tmdbId)
return null
}
seasonCache.put(key, votes)
return votes
}
/** /**
* @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request * @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request
* @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now * @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.PlaceholderVerticalAlign
@ -100,16 +101,19 @@ private fun rememberBadges(textStyle: TextStyle): Map<String, InlineTextContent>
* The text for the ratings: each as a badge and its number, separated by dots * The text for the ratings: each as a badge and its number, separated by dots
* *
* @param leadingDot whether a dot is put in front of the first rating because something precedes it * @param leadingDot whether a dot is put in front of the first rating because something precedes it
* @param episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score
*/ */
fun ratingsText( fun ratingsText(
entries: List<RatingEntry>, entries: List<RatingEntry>,
leadingDot: Boolean, leadingDot: Boolean,
episodeLabel: String,
): AnnotatedString = ): AnnotatedString =
buildAnnotatedString { buildAnnotatedString {
entries.forEachIndexed { index, entry -> entries.forEachIndexed { index, entry ->
if (index > 0 || leadingDot) dot() if (index > 0 || leadingDot) dot()
appendInlineContent(entry.badge.name) appendInlineContent(entry.badge.name)
append(" ") append(" ")
if (entry.isEpisode) append("$episodeLabel ")
append(entry.text) append(entry.text)
} }
} }
@ -131,9 +135,10 @@ fun SourceRatingsRow(
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val community = DisplayToggle.COMMUNITY_RATING in enabled val community = DisplayToggle.COMMUNITY_RATING in enabled
val critic = DisplayToggle.CRITIC_RATING in enabled val critic = DisplayToggle.CRITIC_RATING in enabled
val episodeLabel = stringResource(R.string.rating_episode_label)
val text = val text =
remember(ratings, community, critic, leadingDot) { remember(ratings, community, critic, leadingDot, episodeLabel) {
ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot) } ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) }
} ?: return } ?: return
val badges = rememberBadges(textStyle) val badges = rememberBadges(textStyle)
Text( Text(

View file

@ -164,6 +164,7 @@
<string name="season_episode_number_padded">S%1$02dE%2$02d</string> <string name="season_episode_number_padded">S%1$02dE%2$02d</string>
<string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string> <string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string>
<string name="episode_number_short">E%1$d</string> <string name="episode_number_short">E%1$d</string>
<string name="rating_episode_label">Ep</string>
<string name="season_number">Season %1$d</string> <string name="season_number">Season %1$d</string>
<string name="tv_shows_title">TV Shows</string> <string name="tv_shows_title">TV Shows</string>
<string name="ui_interface">Interface</string> <string name="ui_interface">Interface</string>

View file

@ -1097,6 +1097,61 @@ components:
example: 0 example: 0
description: Availability of the season in 4K description: Availability of the season in 4K
readOnly: true readOnly: true
SeasonDetails:
type: object
description: The answer of the season endpoint. Everything TMDB may not know is nullable.
properties:
id:
type: integer
airDate:
type: string
nullable: true
name:
type: string
nullable: true
overview:
type: string
nullable: true
posterPath:
type: string
nullable: true
seasonNumber:
type: integer
episodes:
type: array
items:
$ref: '#/components/schemas/SeasonEpisode'
SeasonEpisode:
type: object
description: An episode of the season endpoint. voteAverage is TMDB's user score as a decimal, 0 when nobody voted.
properties:
id:
type: integer
name:
type: string
nullable: true
overview:
type: string
nullable: true
airDate:
type: string
nullable: true
episodeNumber:
type: integer
seasonNumber:
type: integer
showId:
type: integer
stillPath:
type: string
nullable: true
voteAverage:
type: number
nullable: true
voteCount:
type: integer
format: int64
nullable: true
TvShowStatus: TvShowStatus:
type: string type: string
enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"]
@ -6651,7 +6706,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Season' $ref: '#/components/schemas/SeasonDetails'
/tv/{tvId}/recommendations: /tv/{tvId}/recommendations:
get: get:
summary: Get recommended TV series summary: Get recommended TV series

View file

@ -157,4 +157,48 @@ class SourceRatingsTest {
assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty()) assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty())
assertFalse(movie.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.TvApi
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response 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.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.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.SeerrItemType
@ -265,4 +268,159 @@ class SeerrRatingsItemsTest {
assertEquals(true, cancelled) assertEquals(true, cancelled)
assertNull(service.cached(SeerrItemType.MOVIE, 8)) 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.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response 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.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.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SourceRatings
@ -162,6 +163,47 @@ class SourceRatingsRowTest {
assertJellyfinRatingNotShown() 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 @Test
fun seasonShowsItsSeriesRatings() { fun seasonShowsItsSeriesRatings() {
seriesRatings() seriesRatings()
@ -228,12 +270,12 @@ class SourceRatingsRowTest {
imdbVotes = 10, imdbVotes = 10,
) )
val entries = ratings.visibleEntries(community = true, critic = true) 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("96%") < text.text.indexOf("30%"))
assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8")) assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8"))
// dots only between entries, or also in front when something precedes the ratings // dots only between entries, or also in front when something precedes the ratings
assertEquals(2, text.text.split("\u2022").size - 1) 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)
} }
} }