Add source ratings model and Seerr ratings service matching the PWA

This commit is contained in:
justinzeus 2026-10-29 20:00:31 +01:00
parent d78a23a80a
commit e92ade99df
10 changed files with 1032 additions and 40 deletions

View file

@ -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<RatingEntry.RottenTomatoes>
get() =
listOfNotNull(
rtCritics?.let { RatingEntry.RottenTomatoes(it, true) },
rtAudience?.let { RatingEntry.RottenTomatoes(it, false) },
)
/**
* IMDb or TMDB
*/
val communityEntries: List<RatingEntry>
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<RatingEntry>) {
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) }
},
)
}

View file

@ -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<Key, SourceRatings>()
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<SourceRatings?> {
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
}
}

View file

@ -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),
)
}