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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue