From e92ade99df54d952bdc375f9ead0df5182350639 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 20:00:31 +0100 Subject: [PATCH 1/5] Add source ratings model and Seerr ratings service matching the PWA --- .../wholphin/data/model/SourceRatings.kt | 223 +++++++++++++++++ .../wholphin/services/SeerrRatingsService.kt | 222 +++++++++++++++++ .../wholphin/ui/components/QuickDetails.kt | 75 +++--- app/src/main/res/drawable/ic_rating_imdb.xml | 22 ++ app/src/main/res/drawable/ic_rating_tmdb.xml | 16 ++ app/src/main/res/drawable/ic_rt_spilled.xml | 73 ++++++ app/src/main/res/drawable/ic_rt_upright.xml | 52 ++++ app/src/main/seerr/seerr-api.yml | 14 +- .../wholphin/data/model/SourceRatingsTest.kt | 143 +++++++++++ .../services/SeerrRatingsServiceTest.kt | 232 ++++++++++++++++++ 10 files changed, 1032 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt create mode 100644 app/src/main/res/drawable/ic_rating_imdb.xml create mode 100644 app/src/main/res/drawable/ic_rating_tmdb.xml create mode 100644 app/src/main/res/drawable/ic_rt_spilled.xml create mode 100644 app/src/main/res/drawable/ic_rt_upright.xml create mode 100644 app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt 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 new file mode 100644 index 00000000..451ff57d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -0,0 +1,223 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Immutable +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import com.github.damontecres.wholphin.ui.dot +import java.util.Locale + +/** + * Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage. + */ +enum class RtMark { + FRESH, + ROTTEN, + UPRIGHT, + SPILLED, + + /** Seerr sent a score without a recognised label */ + UNKNOWN, + ; + + companion object { + /** + * Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null + */ + fun fromLabel(label: String?): RtMark? = + when (label) { + "Certified Fresh", "Fresh" -> FRESH + "Rotten" -> ROTTEN + "Upright" -> UPRIGHT + "Spilled" -> SPILLED + else -> null + } + } +} + +@Immutable +data class RtScore( + /** 0-100 */ + val percent: Int, + val mark: RtMark, +) + +/** + * One displayable rating + */ +@Immutable +sealed interface RatingEntry { + val text: String + + /** + * The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge + */ + val inlineContentId: String + + data class RottenTomatoes( + val score: RtScore, + /** Critics (Tomatometer) or audience (Popcornmeter) */ + val critics: Boolean, + ) : RatingEntry { + override val text: String get() = "${score.percent}%" + override val inlineContentId: String + get() = + when (score.mark) { + RtMark.FRESH -> INLINE_RT_FRESH + RtMark.ROTTEN -> INLINE_RT_ROTTEN + RtMark.UPRIGHT -> INLINE_RT_UPRIGHT + RtMark.SPILLED -> INLINE_RT_SPILLED + RtMark.UNKNOWN -> INLINE_RT_UNKNOWN + } + } + + data class Imdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val inlineContentId: String get() = INLINE_IMDB + } + + data class Tmdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val inlineContentId: String get() = INLINE_TMDB + } +} + +const val INLINE_RT_FRESH = "fresh" +const val INLINE_RT_ROTTEN = "rotten" +const val INLINE_RT_UPRIGHT = "rt_upright" +const val INLINE_RT_SPILLED = "rt_spilled" +const val INLINE_RT_UNKNOWN = "rt_unknown" +const val INLINE_IMDB = "imdb" +const val INLINE_TMDB = "tmdb" + +private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) + +/** + * Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience, + * IMDb (movies only), and TMDB. + * + * Only scores that were actually observed are present. Use [of] to apply the PWA's rules. + */ +@Immutable +data class SourceRatings( + val rtCritics: RtScore? = null, + val rtAudience: RtScore? = null, + val imdb: Double? = null, + val tmdb: Double? = null, +) { + val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null + + /** + * Rotten Tomatoes entries in PWA order: critics then audience + */ + val criticEntries: List + get() = + listOfNotNull( + rtCritics?.let { RatingEntry.RottenTomatoes(it, true) }, + rtAudience?.let { RatingEntry.RottenTomatoes(it, false) }, + ) + + /** + * IMDb or TMDB + */ + val communityEntries: List + get() = + listOfNotNull( + imdb?.let { RatingEntry.Imdb(it) }, + tmdb?.let { RatingEntry.Tmdb(it) }, + ) + + companion object { + const val MAX_PERCENT = 100.0 + const val MAX_SCORE = 10.0 + + /** + * 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 + * - A zero-vote IMDb/TMDB score is unrated, never 0 + * - RT icon comes from the label Seerr reports + * - TMDB is kept only when no other source has a score + * + * @param rtCriticsLabel Seerr's critics status: Certified Fresh, Fresh, or Rotten + * @param rtAudienceLabel Seerr's audience status: Upright or Spilled + */ + fun of( + rtCriticsScore: Number? = null, + rtCriticsLabel: String? = null, + rtAudienceScore: Number? = null, + rtAudienceLabel: String? = null, + imdbScore: Number? = null, + imdbVotes: Long? = null, + tmdbScore: Number? = null, + tmdbVotes: Long? = null, + ): SourceRatings { + val critics = rtScore(rtCriticsScore, rtCriticsLabel, RtMark.UNKNOWN) + val audience = rtScore(rtAudienceScore, rtAudienceLabel, RtMark.UPRIGHT) + val imdb = userScore(imdbScore, imdbVotes) + val tmdb = userScore(tmdbScore, tmdbVotes) + return if (critics != null || audience != null || imdb != null) { + SourceRatings(critics, audience, imdb, null) + } else { + SourceRatings(tmdb = tmdb) + } + } + + private fun rtScore( + score: Number?, + label: String?, + defaultMark: RtMark, + ): RtScore? { + val value = score?.toDouble() ?: return null + if (value.isNaN() || value < 0.0 || value > MAX_PERCENT) return null + return RtScore(value.toInt(), RtMark.fromLabel(label) ?: defaultMark) + } + + private fun userScore( + score: Number?, + votes: Long?, + ): Double? { + val value = score?.toDouble() ?: return null + if (value.isNaN() || value < 0.0 || value > MAX_SCORE) return null + if (votes != null && votes <= 0L) return null + return value + } + } +} + +/** + * Appends each entry as a dot, its badge, and its score + */ +fun AnnotatedString.Builder.appendRatings(entries: List) { + entries.forEach { + dot() + appendInlineContent(it.inlineContentId) + append(" ") + append(it.text) + } +} + +/** + * Replaces the Jellyfin community & critic ratings with these ratings. + * + * IMDb/TMDB are shown with the community rating setting, Rotten Tomatoes with the critic rating setting. + * If there are no ratings, the Jellyfin values are kept. + */ +fun QuickDetailsData.withSourceRatings(ratings: SourceRatings?): QuickDetailsData = + if (ratings == null || ratings.isEmpty) { + this + } else { + copy( + communityRating = + ratings.communityEntries.takeIf { it.isNotEmpty() }?.let { + buildAnnotatedString { appendRatings(it) } + }, + criticRating = + ratings.criticEntries.takeIf { it.isNotEmpty() }?.let { + buildAnnotatedString { appendRatings(it) } + }, + ) + } 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 new file mode 100644 index 00000000..9e5287a2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -0,0 +1,222 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +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 +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.google.common.cache.CacheBuilder +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.mapLatest +import org.jellyfin.sdk.model.api.BaseItemKind +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The TMDB user vote average & count as reported by Seerr's movie/TV details + */ +data class TmdbVote( + val average: Double?, + val count: Long?, +) + +/** + * Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses + * + * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from + * `voteAverage`/`voteCount` and is only requested when no other source has a score. + * + * Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class SeerrRatingsService + @Inject + constructor( + private val seerrService: SeerrService, + ) { + private data class Key( + val type: SeerrItemType, + val tmdbId: Int, + ) + + private val cache = + CacheBuilder + .newBuilder() + .maximumSize(CACHE_SIZE) + .build() + + fun cached( + type: SeerrItemType, + tmdbId: Int, + ): SourceRatings? = cache.getIfPresent(Key(type, tmdbId)) + + /** + * Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the + * connection is established asynchronously after login. + * + * Emits null while Seerr is not active, the item isn't a movie/series with a TMDB id, or the lookup failed. + * Callers should keep showing the Jellyfin values for null. + */ + fun forItem(item: BaseItem): Flow { + val type = + when (item.type) { + BaseItemKind.MOVIE -> SeerrItemType.MOVIE + BaseItemKind.SERIES -> SeerrItemType.TV + else -> null + } + val tmdbId = + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + return if (type == null || tmdbId == null) { + flowOf(null) + } else { + seerrService.active + .distinctUntilChanged() + .mapLatest { active -> if (active) get(type, tmdbId) else null } + } + } + + /** + * @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 + */ + suspend fun get( + type: SeerrItemType, + tmdbId: Int, + knownTmdb: TmdbVote? = null, + ): SourceRatings? { + val key = Key(type, tmdbId) + cache.getIfPresent(key)?.let { return it } + if (type != SeerrItemType.MOVIE && type != SeerrItemType.TV) return null + + val combined = + try { + fetchCombined(type, tmdbId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.w(ex, "Error fetching ratings for %s %s", type, tmdbId) + return null + } + var ratings = build(combined, null) + if (ratings.isEmpty) { + // Nothing else has a score, so TMDB is the only possible source + val vote = + knownTmdb ?: try { + fetchTmdb(type, tmdbId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.w(ex, "Error fetching TMDB rating for %s %s", type, tmdbId) + return null + } + ratings = build(combined, vote) + } + cache.put(key, ratings) + return ratings + } + + private suspend fun fetchCombined( + type: SeerrItemType, + tmdbId: Int, + ): Combined = + try { + when (type) { + SeerrItemType.MOVIE -> { + Combined.Movie(seerrService.api.moviesApi.movieMovieIdRatingscombinedGet(movieId = tmdbId)) + } + + else -> { + Combined.Tv(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = tmdbId)) + } + } + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + // Seerr has no RT/IMDb data for this title, that is an answer rather than a failure + Timber.d("No ratings for %s %s", type, tmdbId) + Combined.None + } else { + throw ex + } + } + + private suspend fun fetchTmdb( + type: SeerrItemType, + tmdbId: Int, + ): TmdbVote = + when (type) { + SeerrItemType.MOVIE -> { + seerrService.api.moviesApi + .movieMovieIdGet(movieId = tmdbId) + .let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) } + } + + else -> { + seerrService.api.tvApi + .tvTvIdGet(tvId = tmdbId) + .let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) } + } + } + + private fun build( + combined: Combined, + tmdb: TmdbVote?, + ): SourceRatings = + when (combined) { + is Combined.Movie -> buildMovie(combined.response, tmdb) + is Combined.Tv -> { + val rt = combined.response + SourceRatings.of( + rtCriticsScore = rt.criticsScore, + rtCriticsLabel = rt.criticsRating?.value, + rtAudienceScore = rt.audienceScore, + rtAudienceLabel = rt.audienceRating?.value, + tmdbScore = tmdb?.average, + tmdbVotes = tmdb?.count, + ) + } + + Combined.None -> { + SourceRatings.of(tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count) + } + } + + private fun buildMovie( + response: MovieMovieIdRatingscombinedGet200Response, + tmdb: TmdbVote?, + ): SourceRatings = + SourceRatings.of( + rtCriticsScore = response.rt?.criticsScore, + rtCriticsLabel = response.rt?.criticsRating?.value, + rtAudienceScore = response.rt?.audienceScore, + rtAudienceLabel = response.rt?.audienceRating?.value, + imdbScore = response.imdb?.criticsScore, + imdbVotes = response.imdb?.criticsScoreCount, + tmdbScore = tmdb?.average, + tmdbVotes = tmdb?.count, + ) + + private sealed interface Combined { + data class Movie( + val response: MovieMovieIdRatingscombinedGet200Response, + ) : Combined + + data class Tv( + val response: TvTvIdRatingsGet200Response, + ) : Combined + + data object None : Combined + } + + companion object { + private const val CACHE_SIZE = 300L + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index 6db89c75..267677e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -1,5 +1,7 @@ package com.github.damontecres.wholphin.ui.components +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.text.InlineTextContent @@ -10,7 +12,9 @@ import androidx.compose.runtime.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource @@ -23,6 +27,13 @@ import androidx.tv.material3.Icon import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.INLINE_IMDB +import com.github.damontecres.wholphin.data.model.INLINE_RT_FRESH +import com.github.damontecres.wholphin.data.model.INLINE_RT_ROTTEN +import com.github.damontecres.wholphin.data.model.INLINE_RT_SPILLED +import com.github.damontecres.wholphin.data.model.INLINE_RT_UNKNOWN +import com.github.damontecres.wholphin.data.model.INLINE_RT_UPRIGHT +import com.github.damontecres.wholphin.data.model.INLINE_TMDB import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.preferences.DisplayToggle import com.github.damontecres.wholphin.ui.dot @@ -84,15 +95,26 @@ fun QuickDetailsText( @Composable fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typography.titleSmall) = remember(textStyle) { + val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) + // Wordmark badges are wider than tall, like in the PWA + val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) + fun badge( + placeholder: Placeholder, + @DrawableRes resId: Int, + muted: Boolean = false, + ) = InlineTextContent(placeholder) { + Image( + painter = painterResource(resId), + contentDescription = null, + contentScale = ContentScale.Fit, + alpha = if (muted) 0.55f else 1f, + colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null, + modifier = Modifier.fillMaxSize(), + ) + } mapOf( "star" to - InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter, - ), - ) { + InlineTextContent(square) { Icon( imageVector = Icons.Filled.Star, tint = FilledStarColor, @@ -100,36 +122,13 @@ fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typograp modifier = Modifier.fillMaxSize(), ) }, - "rotten" to - InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - tint = Color.Unspecified, - ) - }, - "fresh" to - InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - tint = Color.Unspecified, - ) - }, + INLINE_RT_ROTTEN to badge(square, R.drawable.ic_rotten_tomatoes_rotten), + INLINE_RT_FRESH to badge(square, R.drawable.ic_rotten_tomatoes_fresh), + INLINE_RT_UPRIGHT to badge(square, R.drawable.ic_rt_upright), + INLINE_RT_SPILLED to badge(square, R.drawable.ic_rt_spilled), + INLINE_RT_UNKNOWN to badge(square, R.drawable.ic_rotten_tomatoes_fresh, muted = true), + INLINE_IMDB to badge(wide, R.drawable.ic_rating_imdb), + INLINE_TMDB to badge(wide, R.drawable.ic_rating_tmdb), ) } diff --git a/app/src/main/res/drawable/ic_rating_imdb.xml b/app/src/main/res/drawable/ic_rating_imdb.xml new file mode 100644 index 00000000..6725d42f --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_imdb.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_rating_tmdb.xml b/app/src/main/res/drawable/ic_rating_tmdb.xml new file mode 100644 index 00000000..31c0be7d --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_tmdb.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_rt_spilled.xml b/app/src/main/res/drawable/ic_rt_spilled.xml new file mode 100644 index 00000000..fea81f97 --- /dev/null +++ b/app/src/main/res/drawable/ic_rt_spilled.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_rt_upright.xml b/app/src/main/res/drawable/ic_rt_upright.xml new file mode 100644 index 00000000..e7166b39 --- /dev/null +++ b/app/src/main/res/drawable/ic_rt_upright.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 354f3298..665e1e90 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -6590,8 +6590,12 @@ paths: type: string example: 'https://www.imdb.com/title/tt0480249' criticsScore: + type: number + example: 8.8 + criticsScoreCount: type: integer - example: 6.5 + format: int64 + example: 2500000 /tv/{tvId}: get: summary: Get TV details @@ -6773,7 +6777,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh'] + enum: ['Rotten', 'Fresh', 'Certified Fresh'] + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + enum: ['Spilled', 'Upright'] /person/{personId}: get: summary: Get person details 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 new file mode 100644 index 00000000..e91bfd29 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -0,0 +1,143 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.compose.ui.text.AnnotatedString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SourceRatingsTest { + @Test + fun movieShowsRtCriticsRtAudienceAndImdbButNotTmdb() { + val ratings = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 90, + rtAudienceLabel = "Upright", + imdbScore = 8.8, + imdbVotes = 2_500_000, + tmdbScore = 8.4, + tmdbVotes = 30_000, + ) + assertEquals(RtScore(96, RtMark.FRESH), ratings.rtCritics) + assertEquals(RtScore(90, RtMark.UPRIGHT), ratings.rtAudience) + assertEquals(8.8, ratings.imdb!!, 0.0) + assertNull(ratings.tmdb) + } + + @Test + fun tmdbIsShownOnlyWhenNothingElseIs() { + val ratings = SourceRatings.of(tmdbScore = 7.633, tmdbVotes = 120) + assertEquals(7.633, ratings.tmdb!!, 0.0) + assertNull(ratings.rtCritics) + assertNull(ratings.imdb) + assertEquals(listOf("7.6"), ratings.communityEntries.map { it.text }) + + // A single non-TMDB source hides TMDB + assertNull(SourceRatings.of(rtAudienceScore = 40, tmdbScore = 7.0, tmdbVotes = 10).tmdb) + assertNull(SourceRatings.of(imdbScore = 5.0, imdbVotes = 10, tmdbScore = 7.0, tmdbVotes = 10).tmdb) + } + + @Test + fun zeroVoteScoresAreUnratedNotZero() { + val ratings = SourceRatings.of(imdbScore = 0.0, imdbVotes = 0, tmdbScore = 0.0, tmdbVotes = 0) + assertTrue(ratings.isEmpty) + + // A zero-vote IMDb score doesn't count as a source, so TMDB can still be the fallback + val withTmdb = SourceRatings.of(imdbScore = 6.5, imdbVotes = 0, tmdbScore = 7.1, tmdbVotes = 55) + assertNull(withTmdb.imdb) + assertEquals(7.1, withTmdb.tmdb!!, 0.0) + } + + @Test + fun missingVoteCountIsStillObserved() { + assertEquals(8.8, SourceRatings.of(imdbScore = 8.8).imdb!!, 0.0) + } + + @Test + fun outOfRangeScoresAreDropped() { + assertTrue(SourceRatings.of(rtCriticsScore = 101, rtAudienceScore = -1).isEmpty) + assertTrue(SourceRatings.of(imdbScore = 10.5, imdbVotes = 5, tmdbScore = -0.1, tmdbVotes = 5).isEmpty) + assertTrue(SourceRatings.of(imdbScore = 7.0, imdbVotes = -1).isEmpty) + } + + @Test + fun rtMarkComesFromSeerrLabelNotThePercentage() { + // 95% but labelled Rotten and 10% labelled Fresh: the label wins + assertEquals(RtMark.ROTTEN, SourceRatings.of(rtCriticsScore = 95, rtCriticsLabel = "Rotten").rtCritics!!.mark) + assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 10, rtCriticsLabel = "Fresh").rtCritics!!.mark) + assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 80, rtCriticsLabel = "Certified Fresh").rtCritics!!.mark) + assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 20, rtAudienceLabel = "Upright").rtAudience!!.mark) + assertEquals(RtMark.SPILLED, SourceRatings.of(rtAudienceScore = 90, rtAudienceLabel = "Spilled").rtAudience!!.mark) + } + + @Test + fun rtWithoutKnownLabelUsesPwaDefaults() { + assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90).rtCritics!!.mark) + assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90, rtCriticsLabel = "Mystery").rtCritics!!.mark) + assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 90).rtAudience!!.mark) + } + + @Test + fun statusLabelMapping() { + assertEquals(RtMark.FRESH, RtMark.fromLabel("Certified Fresh")) + assertEquals(RtMark.FRESH, RtMark.fromLabel("Fresh")) + assertEquals(RtMark.ROTTEN, RtMark.fromLabel("Rotten")) + assertEquals(RtMark.UPRIGHT, RtMark.fromLabel("Upright")) + assertEquals(RtMark.SPILLED, RtMark.fromLabel("Spilled")) + assertNull(RtMark.fromLabel(null)) + assertNull(RtMark.fromLabel("fresh")) + } + + @Test + fun entriesUseThePwaTextAndBadges() { + val ratings = + SourceRatings.of( + rtCriticsScore = 85, + rtCriticsLabel = "Fresh", + rtAudienceScore = 40, + rtAudienceLabel = "Spilled", + imdbScore = 8.0, + imdbVotes = 1, + ) + assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text }) + assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId }) + assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) + assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId }) + } + + private val jellyfinDetails = + QuickDetailsData( + basic = AnnotatedString("2020"), + communityRating = AnnotatedString("7.8 jellyfin"), + criticRating = AnnotatedString("59% jellyfin"), + ) + + @Test + fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() { + assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null)) + assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings())) + } + + @Test + fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() { + val details = + jellyfinDetails.withSourceRatings( + SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10), + ) + // COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes + assertTrue(details.communityRating!!.text.endsWith("8.8")) + assertTrue(details.criticRating!!.text.endsWith("96%")) + assertEquals(jellyfinDetails.basic, details.basic) + } + + @Test + fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() { + // Seerr has only RT: the Jellyfin star is not shown next to it + val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh")) + assertNull(details.communityRating) + assertTrue(details.criticRating!!.text.endsWith("50%")) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt new file mode 100644 index 00000000..8f4a2627 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt @@ -0,0 +1,232 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.MoviesApi +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.TvApi +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +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.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.RtMark +import com.github.damontecres.wholphin.data.model.SeerrItemType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.util.UUID + +class SeerrRatingsServiceTest { + private val moviesApi = mockk() + private val tvApi = mockk() + private val apiClient = + mockk { + every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi + every { tvApi } returns this@SeerrRatingsServiceTest.tvApi + } + private var activeFlow = flowOf(true) + private val seerrService = + mockk { + every { api } returns apiClient + every { active } answers { activeFlow } + } + private val service = SeerrRatingsService(seerrService) + + private fun movieDetails( + average: Double?, + count: Int?, + ) = mockk { + every { voteAverage } returns average?.toBigDecimal() + every { voteCount } returns count + } + + private fun tvDetails( + average: Double?, + count: Int?, + ) = mockk { + every { voteAverage } returns average?.toBigDecimal() + every { voteCount } returns count + } + + private val fullMovieRatings = + MovieMovieIdRatingscombinedGet200Response( + rt = + MovieMovieIdRatingsGet200Response( + criticsScore = 96, + criticsRating = MovieMovieIdRatingsGet200Response.CriticsRating.CERTIFIED_FRESH, + audienceScore = 90, + audienceRating = MovieMovieIdRatingsGet200Response.AudienceRating.UPRIGHT, + ), + imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000), + ) + + @Test + fun movieUsesRatingsCombinedAndDoesNotRequestTmdbWhenOtherSourcesExist() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 10) } returns fullMovieRatings + + val ratings = service.get(SeerrItemType.MOVIE, 10)!! + + assertEquals(96, ratings.rtCritics?.percent) + assertEquals(RtMark.FRESH, ratings.rtCritics?.mark) + assertEquals(90, ratings.rtAudience?.percent) + assertEquals(8.8, ratings.imdb!!, 0.0) + assertNull(ratings.tmdb) + coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) } + } + + @Test + fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() = + runTest { + coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns + TvTvIdRatingsGet200Response( + criticsScore = 70, + criticsRating = TvTvIdRatingsGet200Response.CriticsRating.FRESH, + audienceScore = 30, + audienceRating = TvTvIdRatingsGet200Response.AudienceRating.SPILLED, + ) + + val ratings = service.get(SeerrItemType.TV, 20)!! + + assertEquals(RtMark.FRESH, ratings.rtCritics?.mark) + assertEquals(RtMark.SPILLED, ratings.rtAudience?.mark) + assertNull(ratings.imdb) + } + + @Test + fun tmdbIsFetchedOnlyAsFallbackAndZeroVotesAreUnrated() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 1) } returns + MovieMovieIdRatingscombinedGet200Response() + coEvery { moviesApi.movieMovieIdGet(movieId = 1) } returns movieDetails(7.4, 321) + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 2) } returns + MovieMovieIdRatingscombinedGet200Response() + coEvery { moviesApi.movieMovieIdGet(movieId = 2) } returns movieDetails(0.0, 0) + + assertEquals(7.4, service.get(SeerrItemType.MOVIE, 1)!!.tmdb!!, 0.001) + assertTrue(service.get(SeerrItemType.MOVIE, 2)!!.isEmpty) + } + + @Test + fun knownTmdbVoteAvoidsExtraRequest() = + runTest { + coEvery { tvApi.tvTvIdRatingsGet(tvId = 3) } returns TvTvIdRatingsGet200Response() + + val ratings = service.get(SeerrItemType.TV, 3, TmdbVote(8.1, 900))!! + + assertEquals(8.1, ratings.tmdb!!, 0.0) + coVerify(exactly = 0) { tvApi.tvTvIdGet(any()) } + } + + @Test + fun movieAndSeriesWithTheSameTmdbIdDoNotShareCachedRatings() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings + coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns + TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = TvTvIdRatingsGet200Response.CriticsRating.ROTTEN) + + val movie = service.get(SeerrItemType.MOVIE, 603)!! + val tv = service.get(SeerrItemType.TV, 603)!! + + assertEquals(96, movie.rtCritics?.percent) + assertEquals(12, tv.rtCritics?.percent) + assertEquals(RtMark.ROTTEN, tv.rtCritics?.mark) + assertEquals(movie, service.cached(SeerrItemType.MOVIE, 603)) + assertEquals(tv, service.cached(SeerrItemType.TV, 603)) + } + + @Test + fun successIsCachedSoSeerrIsAskedOnce() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns fullMovieRatings + + service.get(SeerrItemType.MOVIE, 5) + service.get(SeerrItemType.MOVIE, 5) + + coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } + } + + @Test + fun failuresAreNotCachedAndAreRetried() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } throws IOException("down") andThen fullMovieRatings + + assertNull(service.get(SeerrItemType.MOVIE, 7)) + assertNull(service.cached(SeerrItemType.MOVIE, 7)) + assertNotNull(service.get(SeerrItemType.MOVIE, 7)?.rtCritics) + } + + @Test + fun serverErrorIsAFailureButNotFoundIsAnAnswer() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } throws ClientException("boom", 500) + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 9) } throws ClientException("none", 404) + coEvery { moviesApi.movieMovieIdGet(movieId = 9) } returns movieDetails(6.0, 40) + + assertNull(service.get(SeerrItemType.MOVIE, 8)) + assertNull(service.cached(SeerrItemType.MOVIE, 8)) + + // 404 from ratings means no RT/IMDb data, TMDB is still available + assertEquals(6.0, service.get(SeerrItemType.MOVIE, 9)!!.tmdb!!, 0.0) + assertNotNull(service.cached(SeerrItemType.MOVIE, 9)) + } + + @Test + fun failedTmdbFallbackIsNotCachedAsNoRating() = + runTest { + coEvery { tvApi.tvTvIdRatingsGet(tvId = 11) } returns TvTvIdRatingsGet200Response() + coEvery { tvApi.tvTvIdGet(tvId = 11) } throws IOException("down") andThen tvDetails(7.0, 10) + + assertNull(service.get(SeerrItemType.TV, 11)) + assertEquals(7.0, service.get(SeerrItemType.TV, 11)!!.tmdb!!, 0.0) + } + + private fun jellyfinItem( + type: BaseItemKind, + providerIds: Map?, + ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds)) + + @Test + fun jellyfinItemResolvesRatingsThroughItsTmdbId() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings + + val ratings = service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first() + + assertEquals(96, ratings?.rtCritics?.percent) + } + + @Test + fun jellyfinItemFallsBackToNullWhenSeerrCannotAnswer() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns fullMovieRatings + coEvery { tvApi.tvTvIdRatingsGet(any()) } throws IOException("down") + + // no TMDB id + assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first()) + assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first()) + // not a movie/series + assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first()) + assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first()) + // call fails + assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first()) + // Seerr not configured + activeFlow = flowOf(false) + assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first()) + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + } +} -- 2.49.1 From 15b082ccd8306a91ee7f787c1731d78fe79ee67b Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 20:00:31 +0100 Subject: [PATCH 2/5] Show PWA source ratings on Discover screens --- .../wholphin/data/model/DiscoverItem.kt | 19 ------ .../damontecres/wholphin/ui/Formatting.kt | 22 ++---- .../detail/discover/DiscoverMovieDetails.kt | 4 +- .../discover/DiscoverMovieDetailsHeader.kt | 7 +- .../detail/discover/DiscoverMovieViewModel.kt | 17 +++-- .../detail/discover/DiscoverSeriesDetails.kt | 9 ++- .../discover/DiscoverSeriesViewModel.kt | 15 ++-- .../ui/detail/discover/DiscoverUtils.kt | 23 ------- .../wholphin/ui/discover/SeerrDiscoverPage.kt | 68 +++++-------------- 9 files changed, 53 insertions(+), 131 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index 24d7d340..fe4650a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -3,8 +3,6 @@ package com.github.damontecres.wholphin.data.model import androidx.compose.runtime.Stable -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.LocalDateSerializer @@ -123,20 +121,3 @@ data class DiscoverItem( } } } - -/** - * A rating for a discovered item which is usually fetched separately from the item - */ -data class DiscoverRating( - val criticRating: Int?, - val audienceRating: Float?, -) { - constructor(rating: MovieMovieIdRatingsGet200Response) : this( - criticRating = rating.criticsScore, - audienceRating = rating.audienceScore?.div(10f), - ) - constructor(rating: TvTvIdRatingsGet200Response) : this( - criticRating = rating.criticsScore, - audienceRating = null, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 282c161e..6096e967 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -4,11 +4,12 @@ import android.content.Context import android.content.res.Resources import android.text.format.DateFormat import androidx.annotation.StringRes -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.WholphinApplication +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.data.model.appendRatings import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.MediaSegmentType @@ -234,27 +235,16 @@ fun AnnotatedString.Builder.dot() = append(" \u2022 ") fun listToDotString( strings: List, - communityRating: Float?, - criticRating: Float?, + ratings: SourceRatings?, ): AnnotatedString = buildAnnotatedString { strings.forEachIndexed { index, string -> append(string) if (index != strings.lastIndex) dot() } - communityRating?.let { - dot() - append(String.format(Locale.getDefault(), "%.1f", it)) - appendInlineContent(id = "star") - } - criticRating?.let { - dot() - append("${it.toInt()}%") - if (it >= 60f) { - appendInlineContent(id = "fresh") - } else { - appendInlineContent(id = "rotten") - } + ratings?.let { + appendRatings(it.communityEntries) + appendRatings(it.criticEntries) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index e91dfb82..c5686e8f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -38,7 +38,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -185,7 +185,7 @@ fun DiscoverMovieDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, canCancel: Boolean, people: List, trailers: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 1c0915ec..51a537e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -20,7 +20,7 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.jellyfinId import com.github.damontecres.wholphin.ui.LocalImageUrlService @@ -44,7 +44,7 @@ import kotlin.time.Duration.Companion.minutes fun DiscoverMovieDetailsHeader( preferences: UserPreferences, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, showLogo: Boolean, @@ -107,8 +107,7 @@ fun DiscoverMovieDetailsHeader( }.let { listToDotString( it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), + rating, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 20cecd12..658e4ffe 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrItemType @@ -19,9 +19,11 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TmdbVote import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault @@ -60,6 +62,7 @@ class DiscoverMovieViewModel private val backdropService: BackdropService, val serverRepository: ServerRepository, val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val seerrServerRepository: SeerrServerRepository, @Assisted val item: DiscoverItem, ) : ViewModel() { @@ -106,11 +109,11 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val rating = - getDiscoverRating(item.id) { - DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id), - ) - } + ratingsService.get( + SeerrItemType.MOVIE, + item.id, + TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()), + ) _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -276,7 +279,7 @@ fun canUserCancelRequest( data class DiscoverMovieState( val movie: DataLoadingState = DataLoadingState.Pending, - val rating: DiscoverRating? = null, + val rating: SourceRatings? = null, val seasons: List = emptyList(), val trailers: List = emptyList(), val people: List = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index 049f7610..88fa0b50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrPermission import com.github.damontecres.wholphin.data.model.Trailer @@ -211,7 +211,7 @@ fun DiscoverSeriesDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, series: TvDetails, - rating: DiscoverRating?, + rating: SourceRatings?, canCancel: Boolean, seasons: List, similar: List, @@ -422,7 +422,7 @@ fun DiscoverSeriesDetailsContent( @Composable fun DiscoverSeriesDetailsHeader( series: TvDetails, - rating: DiscoverRating?, + rating: SourceRatings?, overviewOnClick: () -> Unit, showLogo: Boolean, modifier: Modifier = Modifier, @@ -466,8 +466,7 @@ fun DiscoverSeriesDetailsHeader( }.let { listToDotString( it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), + rating, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index ec500351..b4d6f37f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutReques import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.TmdbVote import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -60,6 +62,7 @@ class DiscoverSeriesViewModel private val backdropService: BackdropService, val serverRepository: ServerRepository, val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val seerrServerRepository: SeerrServerRepository, @Assisted val item: DiscoverItem, ) : ViewModel() { @@ -109,9 +112,11 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { val rating = - getDiscoverRating(item.id) { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) - } + ratingsService.get( + SeerrItemType.TV, + item.id, + TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()), + ) _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -416,7 +421,7 @@ class DiscoverSeriesViewModel data class DiscoverSeriesState( val tvSeries: DataLoadingState = DataLoadingState.Pending, - val rating: DiscoverRating? = null, + val rating: SourceRatings? = null, val seasons: List = emptyList(), val seasons4k: List = emptyList(), val trailers: List = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt index 3e625d60..cc89159f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt @@ -1,39 +1,16 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.model.MediaInfo -import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.jellyfinId import com.github.damontecres.wholphin.services.jellyfinIdAsString import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.CancellationException import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber -suspend fun getDiscoverRating( - id: Int, - func: suspend () -> DiscoverRating, -): DiscoverRating? = - try { - func.invoke() - } catch (ex: CancellationException) { - throw ex - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - Timber.w("No rating for %s", id) - } else { - Timber.e(ex, "Error fetching rating for %s", id) - } - null - } catch (ex: Exception) { - Timber.e(ex, "Error fetching rating for %s", id) - null - } - /** * Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId] * diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index cd7c6a27..4bdc42ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -32,17 +32,17 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader @@ -57,9 +57,9 @@ import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.successValue -import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.model.UUID @@ -73,11 +73,12 @@ class SeerrDiscoverViewModel constructor( @param:ApplicationContext private val context: Context, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, val navigationManager: NavigationManager, private val backdropService: BackdropService, ) : ViewModel() { val state = MutableStateFlow(DiscoverState()) - val rating = MutableStateFlow>(mapOf()) + val rating = MutableStateFlow, SourceRatings>>(mapOf()) init { viewModelScope.launchIO { @@ -246,50 +247,23 @@ class SeerrDiscoverViewModel } } - private val ratingCache = - CacheBuilder - .newBuilder() - .maximumSize(100) - .build() + private var ratingJob: Job? = null - // TODO this is not very efficient + /** + * Fetch the ratings for the focused item, cancelling any previous request that is still running + */ fun fetchRating( id: Int, type: SeerrItemType, ) { - viewModelScope.launchIO { - val cachedResult = ratingCache.getIfPresent(id) - if (cachedResult != null) { - return@launchIO + val key = type to id + if (key in rating.value) return + ratingJob?.cancel() + ratingJob = + viewModelScope.launchIO { + val result = ratingsService.get(type, id) ?: return@launchIO + rating.update { it + (key to result) } } - val result = - getDiscoverRating(id) { - when (type) { - SeerrItemType.MOVIE -> { - DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id), - ) - } - - SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id)) - } - - SeerrItemType.PERSON -> { - DiscoverRating(null, null) - } - - SeerrItemType.UNKNOWN -> { - DiscoverRating(null, null) - } - } - } ?: DiscoverRating(null, null) - - ratingCache.put(id, result) - rating.update { - ratingCache.asMap().toMap() - } - } } } @@ -424,14 +398,8 @@ fun SeerrDiscoverPage( ?.toString() ?.let(::add) }.let { - val rating = focusedItem.id.let { ratingMap[it] } - val str = - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) - QuickDetailsData(str) + val rating = ratingMap[focusedItem.item.type to focusedItem.id] + QuickDetailsData(listToDotString(it, rating)) } } } -- 2.49.1 From 38db9e5127a18b6847ecbfb3210871a7bce77fe7 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 20:00:31 +0100 Subject: [PATCH 3/5] Show Seerr source ratings on Jellyfin movie and series details --- .../wholphin/ui/detail/movie/MovieDetails.kt | 1 + .../wholphin/ui/detail/movie/MovieDetailsHeader.kt | 6 +++++- .../wholphin/ui/detail/movie/MovieViewModel.kt | 10 ++++++++++ .../wholphin/ui/detail/series/SeriesDetails.kt | 9 ++++++++- .../wholphin/ui/detail/series/SeriesViewModel.kt | 10 ++++++++++ 5 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index ef0d186e..bf8d698f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -339,6 +339,7 @@ fun MovieDetailsContent( chosenStreams = state.chosenStreams, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, + sourceRatings = state.sourceRatings, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index ee8f74f0..13ee747c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -20,6 +20,8 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.data.model.withSourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.HeaderUtils @@ -41,8 +43,10 @@ fun MovieDetailsHeader( bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, + sourceRatings: SourceRatings? = null, ) { val dto = movie.data + val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) } val context = LocalContext.current val scope = rememberCoroutineScope() Column( @@ -64,7 +68,7 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - movie.ui.quickDetails, + quickDetails, movie.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index 939d1331..c531ca2e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService @@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService @@ -57,6 +59,7 @@ class MovieViewModel constructor( private val api: ApiClient, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -152,6 +155,11 @@ class MovieViewModel ) } } + viewModelScope.launchIO { + ratingsService.forItem(movie).collect { ratings -> + _state.update { it.copy(sourceRatings = ratings) } + } + } viewModelScope.launchIO { val results = seerrService.similar(movie).orEmpty() _state.update { @@ -305,6 +313,8 @@ data class MovieState( val extras: List = emptyList(), val similar: List = emptyList(), val discovered: List = emptyList(), + /** Seerr ratings, null until they load or if unavailable */ + val sourceRatings: SourceRatings? = null, val chosenStreams: ChosenStreams? = null, val canDelete: Boolean = false, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index f42961ad..60419e83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -44,8 +44,10 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.studioNames +import com.github.damontecres.wholphin.data.model.withSourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards @@ -253,6 +255,7 @@ fun SeriesDetails( } }, discovered = state.discovered, + sourceRatings = state.sourceRatings, onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, @@ -330,6 +333,7 @@ fun SeriesDetailsContent( extras: List, people: List, discovered: List, + sourceRatings: SourceRatings?, played: Boolean, favorite: Boolean, canDelete: Boolean, @@ -377,6 +381,7 @@ fun SeriesDetailsContent( showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, bringIntoViewRequester = bringIntoViewRequester, + sourceRatings = sourceRatings, modifier = Modifier .fillMaxWidth() @@ -654,9 +659,11 @@ fun SeriesDetailsHeader( overviewOnClick: () -> Unit, bringIntoViewRequester: BringIntoViewRequester, modifier: Modifier = Modifier, + sourceRatings: SourceRatings? = null, ) { val scope = rememberCoroutineScope() val dto = series.data + val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) } Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, @@ -674,7 +681,7 @@ fun SeriesDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - series.ui.quickDetails, + quickDetails, null, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 2b92f71b..abcb82a1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService @@ -20,6 +21,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService @@ -100,6 +102,7 @@ class SeriesViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @@ -251,6 +254,11 @@ class SeriesViewModel _state.update { it.copy(similar = similar) } } } + viewModelScope.launchIO { + ratingsService.forItem(series).collect { ratings -> + _state.update { it.copy(sourceRatings = ratings) } + } + } viewModelScope.launchIO { val results = seerrService.similar(series).orEmpty() _state.update { it.copy(discovered = results) } @@ -895,5 +903,7 @@ data class SeriesState( val peopleInEpisode: PeopleInItem = PeopleInItem(), val discovered: List = emptyList(), val discoverSeries: DiscoverItem? = null, + /** Seerr ratings, null until they load or if unavailable */ + val sourceRatings: SourceRatings? = null, val chosenStreams: ChosenStreams? = null, ) -- 2.49.1 From c54acc9c1d5f37533760fd9da43a7326b43a9f7e Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 20:15:08 +0100 Subject: [PATCH 4/5] Show Rotten Tomatoes before IMDb like the PWA --- .../damontecres/wholphin/data/model/BaseItem.kt | 4 ++++ .../wholphin/data/model/SourceRatings.kt | 1 + .../github/damontecres/wholphin/ui/Formatting.kt | 2 +- .../wholphin/ui/components/QuickDetails.kt | 13 ++++++++----- .../wholphin/data/model/SourceRatingsTest.kt | 4 ++++ 5 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 94dc23c2..de3a8f5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -371,6 +371,10 @@ data class QuickDetailsData( val officialRating: AnnotatedString? = null, val criticRating: AnnotatedString? = null, val communityRating: AnnotatedString? = null, + /** + * Show [criticRating] (Rotten Tomatoes) before [communityRating] (IMDb/TMDB) like the PWA, instead of the Jellyfin order + */ + val criticRatingFirst: Boolean = false, ) /** 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 451ff57d..1085a8a6 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 @@ -219,5 +219,6 @@ fun QuickDetailsData.withSourceRatings(ratings: SourceRatings?): QuickDetailsDat ratings.criticEntries.takeIf { it.isNotEmpty() }?.let { buildAnnotatedString { appendRatings(it) } }, + criticRatingFirst = true, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index 6096e967..30521965 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -243,8 +243,8 @@ fun listToDotString( if (index != strings.lastIndex) dot() } ratings?.let { - appendRatings(it.communityEntries) appendRatings(it.criticEntries) + appendRatings(it.communityEntries) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index 267677e3..d4228be0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -59,11 +59,14 @@ fun QuickDetails( if (DisplayToggle.OFFICIAL_RATING in enabled) { QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap) } - if (DisplayToggle.COMMUNITY_RATING in enabled) { - QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap) - } - if (DisplayToggle.CRITIC_RATING in enabled) { - QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) + val community = DisplayToggle.COMMUNITY_RATING in enabled + val critic = DisplayToggle.CRITIC_RATING in enabled + if (details.criticRatingFirst) { + if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) + if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap) + } else { + if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap) + if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) } } if (timeRemaining != null) { 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 e91bfd29..41fee583 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 @@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.data.model import androidx.compose.ui.text.AnnotatedString import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -131,6 +132,9 @@ class SourceRatingsTest { assertTrue(details.communityRating!!.text.endsWith("8.8")) assertTrue(details.criticRating!!.text.endsWith("96%")) assertEquals(jellyfinDetails.basic, details.basic) + // PWA order: Rotten Tomatoes before IMDb + assertTrue(details.criticRatingFirst) + assertFalse(jellyfinDetails.criticRatingFirst) } @Test -- 2.49.1 From 04d659b115c648fcd718bca0397677e4baeddbdb Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 20:28:51 +0100 Subject: [PATCH 5/5] Tolerate unknown RT labels, expire cached ratings, avoid duplicate collectors and Jellyfin flash --- .../wholphin/services/SeerrRatingsService.kt | 52 +++++++++++-------- .../ui/detail/movie/MovieViewModel.kt | 14 +++-- .../ui/detail/series/SeriesViewModel.kt | 3 +- app/src/main/seerr/seerr-api.yml | 12 ++--- .../services/SeerrRatingsServiceTest.kt | 46 +++++++++++++--- 5 files changed, 89 insertions(+), 38 deletions(-) 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 9e5287a2..bc59f211 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 @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -50,6 +51,7 @@ class SeerrRatingsService CacheBuilder .newBuilder() .maximumSize(CACHE_SIZE) + .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) .build() fun cached( @@ -57,6 +59,26 @@ class SeerrRatingsService tmdbId: Int, ): SourceRatings? = cache.getIfPresent(Key(type, tmdbId)) + private fun target(item: BaseItem): Pair? { + val type = + when (item.type) { + BaseItemKind.MOVIE -> SeerrItemType.MOVIE + BaseItemKind.SERIES -> SeerrItemType.TV + else -> return null + } + val tmdbId = + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() ?: return null + return type to tmdbId + } + + /** + * The already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it + * doesn't show the Jellyfin values first + */ + fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) } + /** * Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the * connection is established asynchronously after login. @@ -65,23 +87,10 @@ class SeerrRatingsService * Callers should keep showing the Jellyfin values for null. */ fun forItem(item: BaseItem): Flow { - val type = - when (item.type) { - BaseItemKind.MOVIE -> SeerrItemType.MOVIE - BaseItemKind.SERIES -> SeerrItemType.TV - else -> null - } - val tmdbId = - item.data.providerIds - ?.get("Tmdb") - ?.toIntOrNull() - return if (type == null || tmdbId == null) { - flowOf(null) - } else { - seerrService.active - .distinctUntilChanged() - .mapLatest { active -> if (active) get(type, tmdbId) else null } - } + val (type, tmdbId) = target(item) ?: return flowOf(null) + return seerrService.active + .distinctUntilChanged() + .mapLatest { active -> if (active) get(type, tmdbId) else null } } /** @@ -176,9 +185,9 @@ class SeerrRatingsService val rt = combined.response SourceRatings.of( rtCriticsScore = rt.criticsScore, - rtCriticsLabel = rt.criticsRating?.value, + rtCriticsLabel = rt.criticsRating, rtAudienceScore = rt.audienceScore, - rtAudienceLabel = rt.audienceRating?.value, + rtAudienceLabel = rt.audienceRating, tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count, ) @@ -195,9 +204,9 @@ class SeerrRatingsService ): SourceRatings = SourceRatings.of( rtCriticsScore = response.rt?.criticsScore, - rtCriticsLabel = response.rt?.criticsRating?.value, + rtCriticsLabel = response.rt?.criticsRating, rtAudienceScore = response.rt?.audienceScore, - rtAudienceLabel = response.rt?.audienceRating?.value, + rtAudienceLabel = response.rt?.audienceRating, imdbScore = response.imdb?.criticsScore, imdbVotes = response.imdb?.criticsScoreCount, tmdbScore = tmdb?.average, @@ -218,5 +227,6 @@ class SeerrRatingsService companion object { private const val CACHE_SIZE = 300L + private const val CACHE_HOURS = 6L } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index c531ca2e..fc9e7ad6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -103,6 +103,8 @@ class MovieViewModel return item } + private var ratingsJob: Job? = null + fun init(): Job = viewModelScope.launchDefault { val movie = @@ -127,6 +129,7 @@ class MovieViewModel chosenStreams = chosenStreams, trailers = remoteTrailers, chapters = chapters, + sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie), ) } backdropService.submit(movie) @@ -155,11 +158,14 @@ class MovieViewModel ) } } - viewModelScope.launchIO { - ratingsService.forItem(movie).collect { ratings -> - _state.update { it.copy(sourceRatings = ratings) } + // init() runs again on resume and the flow never completes, so don't stack collectors + ratingsJob?.cancel() + ratingsJob = + viewModelScope.launchIO { + ratingsService.forItem(movie).collect { ratings -> + _state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) } + } } - } viewModelScope.launchIO { val results = seerrService.similar(movie).orEmpty() _state.update { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index abcb82a1..caaa1fb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -221,6 +221,7 @@ class SeriesViewModel seasons = seasons, episodes = episodes, trailers = remoteTrailers, + sourceRatings = ratingsService.cachedFor(series), ) } @@ -256,7 +257,7 @@ class SeriesViewModel } viewModelScope.launchIO { ratingsService.forItem(series).collect { ratings -> - _state.update { it.copy(sourceRatings = ratings) } + _state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) } } } viewModelScope.launchIO { diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 665e1e90..d174ccec 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -6528,13 +6528,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined @@ -6573,13 +6573,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' imdb: type: object properties: @@ -6777,13 +6777,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' /person/{personId}: get: summary: Get person details diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt index 8f4a2627..2be93089 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt @@ -4,6 +4,7 @@ import com.github.damontecres.wholphin.api.seerr.MoviesApi import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.TvApi import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.infrastructure.Serializer import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response @@ -67,9 +68,9 @@ class SeerrRatingsServiceTest { rt = MovieMovieIdRatingsGet200Response( criticsScore = 96, - criticsRating = MovieMovieIdRatingsGet200Response.CriticsRating.CERTIFIED_FRESH, + criticsRating = "Certified Fresh", audienceScore = 90, - audienceRating = MovieMovieIdRatingsGet200Response.AudienceRating.UPRIGHT, + audienceRating = "Upright", ), imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000), ) @@ -89,15 +90,42 @@ class SeerrRatingsServiceTest { coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) } } + @Test + fun unknownRtLabelsDoNotDropTheResponse() = + runTest { + val json = Serializer.kotlinxSerializationJson + // Labels Seerr might add later must not fail parsing; the scores still show + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 30) } returns + json.decodeFromString( + """{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" + + """"imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""", + ) + coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns + json.decodeFromString( + """{"criticsScore":70,"criticsRating":"Brand New","audienceScore":60,"audienceRating":"Other"}""", + ) + + val movie = service.get(SeerrItemType.MOVIE, 30)!! + assertEquals(92, movie.rtCritics?.percent) + assertEquals(RtMark.UNKNOWN, movie.rtCritics?.mark) + assertEquals(RtMark.UPRIGHT, movie.rtAudience?.mark) + assertEquals(8.8, movie.imdb!!, 0.0) + + val tv = service.get(SeerrItemType.TV, 31)!! + assertEquals(70, tv.rtCritics?.percent) + assertEquals(RtMark.UNKNOWN, tv.rtCritics?.mark) + assertEquals(60, tv.rtAudience?.percent) + } + @Test fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() = runTest { coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns TvTvIdRatingsGet200Response( criticsScore = 70, - criticsRating = TvTvIdRatingsGet200Response.CriticsRating.FRESH, + criticsRating = "Fresh", audienceScore = 30, - audienceRating = TvTvIdRatingsGet200Response.AudienceRating.SPILLED, + audienceRating = "Spilled", ) val ratings = service.get(SeerrItemType.TV, 20)!! @@ -137,7 +165,7 @@ class SeerrRatingsServiceTest { runTest { coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns - TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = TvTvIdRatingsGet200Response.CriticsRating.ROTTEN) + TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = "Rotten") val movie = service.get(SeerrItemType.MOVIE, 603)!! val tv = service.get(SeerrItemType.TV, 603)!! @@ -205,9 +233,15 @@ class SeerrRatingsServiceTest { runTest { coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings - val ratings = service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first() + val item = jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42")) + assertNull(service.cachedFor(item)) + + val ratings = service.forItem(item).first() assertEquals(96, ratings?.rtCritics?.percent) + // a later screen can start from the cached value instead of the Jellyfin values + assertEquals(ratings, service.cachedFor(item)) + assertNull(service.cachedFor(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "42")))) } @Test -- 2.49.1