From 85365aa3c6c66ef10dffb8dfc91a8de54b21a382 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 21:35:13 +0100 Subject: [PATCH] Show each episode's own TMDB score next to the series ratings --- .../wholphin/data/model/SourceRatings.kt | 35 +++- .../wholphin/services/SeerrRatingsService.kt | 86 +++++++++- .../ui/components/SourceRatingsRow.kt | 9 +- app/src/main/res/values/strings.xml | 1 + app/src/main/seerr/seerr-api.yml | 57 ++++++- .../wholphin/data/model/SourceRatingsTest.kt | 44 +++++ .../services/SeerrRatingsItemsTest.kt | 158 ++++++++++++++++++ .../ui/components/SourceRatingsRowTest.kt | 48 +++++- 8 files changed, 428 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt index f6c6e5e2..581a4aa2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -61,6 +61,11 @@ sealed interface RatingEntry { val text: String 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( val score: RtScore, /** Critics (Tomatometer) or audience (Popcornmeter) */ @@ -91,6 +96,14 @@ sealed interface RatingEntry { override val text: String get() = formatScore(score) 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) @@ -107,8 +120,10 @@ data class SourceRatings( val rtAudience: RtScore? = null, val imdb: 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 @@ -128,8 +143,17 @@ data class SourceRatings( listOfNotNull( imdb?.let { RatingEntry.Imdb(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). * @@ -144,6 +168,15 @@ data class SourceRatings( const val MAX_PERCENT = 100.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: * - 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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt index 20942c78..08d31a9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -49,7 +49,9 @@ data class RatingsTarget( * Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else: * - movies 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 * * 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) .build() + 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>() + /** * 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. @@ -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 */ - 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 @@ -185,12 +213,64 @@ class SeerrRatingsService seerrService.active .distinctUntilChanged() .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? { + 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 * @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt index e2552ace..19567883 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -100,16 +101,19 @@ private fun rememberBadges(textStyle: TextStyle): Map * 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 episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score */ fun ratingsText( entries: List, leadingDot: Boolean, + episodeLabel: String, ): AnnotatedString = buildAnnotatedString { entries.forEachIndexed { index, entry -> if (index > 0 || leadingDot) dot() appendInlineContent(entry.badge.name) append(" ") + if (entry.isEpisode) append("$episodeLabel ") append(entry.text) } } @@ -131,9 +135,10 @@ fun SourceRatingsRow( val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val community = DisplayToggle.COMMUNITY_RATING in enabled val critic = DisplayToggle.CRITIC_RATING in enabled + val episodeLabel = stringResource(R.string.rating_episode_label) val text = - remember(ratings, community, critic, leadingDot) { - ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot) } + remember(ratings, community, critic, leadingDot, episodeLabel) { + ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) } } ?: return val badges = rememberBadges(textStyle) Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b8a3578..84ad72c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,7 @@ S%1$02dE%2$02d S%1$02dE%2$02d-E%3$02d E%1$d + Ep Season %1$d TV Shows Interface diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d174ccec..dc493638 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1097,6 +1097,61 @@ components: example: 0 description: Availability of the season in 4K 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: type: string enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] @@ -6651,7 +6706,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Season' + $ref: '#/components/schemas/SeasonDetails' /tv/{tvId}/recommendations: get: summary: Get recommended TV series diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt index e2471275..e5c12a56 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -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) + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt index 72baa65c..adb5ca33 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt @@ -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) = + 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()) } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt index a51c73a0..4fb3dc5c 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt @@ -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) } }