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