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