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 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

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:
* - 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<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
* 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<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
* @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.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<String, InlineTextContent>
* 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<RatingEntry>,
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(

View file

@ -164,6 +164,7 @@
<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="episode_number_short">E%1$d</string>
<string name="rating_episode_label">Ep</string>
<string name="season_number">Season %1$d</string>
<string name="tv_shows_title">TV Shows</string>
<string name="ui_interface">Interface</string>

View file

@ -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