Merge pull request 'Show PWA source ratings (RT, IMDb, TMDB from Seerr) on the TV' (#24) from fm/wolfskin-23-ratings into main

This commit is contained in:
justinzeus 2026-10-29 20:29:43 +01:00
commit 853a3989b2
25 changed files with 1191 additions and 182 deletions

View file

@ -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,
)
/**

View file

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

View file

@ -0,0 +1,224 @@
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) }
},
criticRatingFirst = true,
)
}

View file

@ -0,0 +1,232 @@
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 java.util.concurrent.TimeUnit
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)
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>()
fun cached(
type: SeerrItemType,
tmdbId: Int,
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
private fun target(item: BaseItem): Pair<SeerrItemType, Int>? {
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.
*
* 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, tmdbId) = target(item) ?: return flowOf(null)
return 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,
rtAudienceScore = rt.audienceScore,
rtAudienceLabel = rt.audienceRating,
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,
rtAudienceScore = response.rt?.audienceScore,
rtAudienceLabel = response.rt?.audienceRating,
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
private const val CACHE_HOURS = 6L
}
}

View file

@ -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<String>,
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.criticEntries)
appendRatings(it.communityEntries)
}
}

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
@ -48,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) {
@ -84,15 +98,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 +125,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),
)
}

View file

@ -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<DiscoverItem>,
trailers: List<Trailer>,

View file

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

View file

@ -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<MovieDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val rating: SourceRatings? = null,
val seasons: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),
val people: List<DiscoverItem> = emptyList(),

View file

@ -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<RequestSeason>,
similar: List<DiscoverItem>,
@ -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,
)
}
}

View file

@ -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<TvDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val rating: SourceRatings? = null,
val seasons: List<RequestSeason> = emptyList(),
val seasons4k: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),

View file

@ -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]
*

View file

@ -339,6 +339,7 @@ fun MovieDetailsContent(
chosenStreams = state.chosenStreams,
bringIntoViewRequester = bringIntoViewRequester,
overviewOnClick = overviewOnClick,
sourceRatings = state.sourceRatings,
modifier =
Modifier
.fillMaxWidth()

View file

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

View file

@ -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,
@ -100,6 +103,8 @@ class MovieViewModel
return item
}
private var ratingsJob: Job? = null
fun init(): Job =
viewModelScope.launchDefault {
val movie =
@ -124,6 +129,7 @@ class MovieViewModel
chosenStreams = chosenStreams,
trailers = remoteTrailers,
chapters = chapters,
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
)
}
backdropService.submit(movie)
@ -152,6 +158,14 @@ class MovieViewModel
)
}
}
// 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 {
@ -305,6 +319,8 @@ data class MovieState(
val extras: List<ExtrasItem> = emptyList(),
val similar: List<BaseItem> = emptyList(),
val discovered: List<DiscoverItem> = emptyList(),
/** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null,
val canDelete: Boolean = false,
) {

View file

@ -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<ExtrasItem>,
people: List<Person>,
discovered: List<DiscoverItem>,
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),
)

View file

@ -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?,
@ -218,6 +221,7 @@ class SeriesViewModel
seasons = seasons,
episodes = episodes,
trailers = remoteTrailers,
sourceRatings = ratingsService.cachedFor(series),
)
}
@ -251,6 +255,11 @@ class SeriesViewModel
_state.update { it.copy(similar = similar) }
}
}
viewModelScope.launchIO {
ratingsService.forItem(series).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO {
val results = seerrService.similar(series).orEmpty()
_state.update { it.copy(discovered = results) }
@ -895,5 +904,7 @@ data class SeriesState(
val peopleInEpisode: PeopleInItem = PeopleInItem(),
val discovered: List<DiscoverItem> = emptyList(),
val discoverSeries: DiscoverItem? = null,
/** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null,
)

View file

@ -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>(DiscoverState())
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
val rating = MutableStateFlow<Map<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf())
init {
viewModelScope.launchIO {
@ -246,50 +247,23 @@ class SeerrDiscoverViewModel
}
}
private val ratingCache =
CacheBuilder
.newBuilder()
.maximumSize(100)
.build<Int, DiscoverRating>()
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))
}
}
}

View file

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="575dp"
android:height="289.83dp"
android:viewportWidth="575"
android:viewportHeight="289.83">
<path
android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"
android:fillColor="#f6c700"/>
<path
android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"
android:fillColor="#000000"/>
<path
android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"
android:fillColor="#000000"/>
<path
android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"
android:fillColor="#000000"/>
<path
android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"
android:fillColor="#000000"/>
</vector>

View file

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="185.04dp"
android:height="133.4dp"
android:viewportWidth="185.04"
android:viewportHeight="133.4">
<path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z">
<aapt:attr name="android:fillColor">
<gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7">
<item android:offset="0" android:color="#90cea1"/>
<item android:offset="0.56" android:color="#3cbec9"/>
<item android:offset="1" android:color="#00b3e5"/>
</gradient>
</aapt:attr>
</path>
</vector>

View file

@ -0,0 +1,73 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="143.75dp"
android:height="108.75dp"
android:viewportWidth="143.75"
android:viewportHeight="108.75">
<path
android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z"
android:fillColor="#07a23b"/>
<path
android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"
android:fillColor="#fff"/>
<path
android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"
android:fillColor="#fff"/>
<path
android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"
android:fillColor="#fff"/>
<path
android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"
android:fillColor="#fff"/>
<path
android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z"
android:fillColor="#03621e"/>
<path
android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"
android:fillColor="#fdd600"/>
<path
android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"
android:fillColor="#fdd600"/>
<path
android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"
android:fillColor="#fdd600"/>
<path
android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"
android:fillColor="#fdd600"/>
<path
android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"
android:fillColor="#fdd600"/>
<path
android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"
android:fillColor="#fdd600"/>
<path
android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"
android:fillColor="#fdd600"/>
<path
android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"
android:fillColor="#fdd600"/>
<path
android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
android:fillColor="#fdd600"/>
<path
android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"
android:fillColor="#fdd600"/>
<path
android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"
android:fillColor="#fdd600"/>
<path
android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"
android:fillColor="#fdd600"/>
<path
android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z"
android:fillColor="#09a339"/>
</vector>

View file

@ -0,0 +1,52 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="106.25dp"
android:height="140dp"
android:viewportWidth="106.25"
android:viewportHeight="140">
<path
android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"
android:fillColor="#fa3106"/>
<path
android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"
android:fillColor="#fff"/>
<path
android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z"
android:fillColor="#ffd600"/>
<path
android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"
android:fillColor="#ffd600"/>
</vector>

View file

@ -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:
@ -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']
example: 'Certified Fresh'
audienceScore:
type: integer
example: 65
audienceRating:
type: string
example: 'Upright'
/person/{personId}:
get:
summary: Get person details

View file

@ -0,0 +1,147 @@
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
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)
// PWA order: Rotten Tomatoes before IMDb
assertTrue(details.criticRatingFirst)
assertFalse(jellyfinDetails.criticRatingFirst)
}
@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%"))
}
}

View file

@ -0,0 +1,266 @@
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.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
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<MoviesApi>()
private val tvApi = mockk<TvApi>()
private val apiClient =
mockk<SeerrApiClient> {
every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi
every { tvApi } returns this@SeerrRatingsServiceTest.tvApi
}
private var activeFlow = flowOf(true)
private val seerrService =
mockk<SeerrService> {
every { api } returns apiClient
every { active } answers { activeFlow }
}
private val service = SeerrRatingsService(seerrService)
private fun movieDetails(
average: Double?,
count: Int?,
) = mockk<MovieDetails> {
every { voteAverage } returns average?.toBigDecimal()
every { voteCount } returns count
}
private fun tvDetails(
average: Double?,
count: Int?,
) = mockk<TvDetails> {
every { voteAverage } returns average?.toBigDecimal()
every { voteCount } returns count
}
private val fullMovieRatings =
MovieMovieIdRatingscombinedGet200Response(
rt =
MovieMovieIdRatingsGet200Response(
criticsScore = 96,
criticsRating = "Certified Fresh",
audienceScore = 90,
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 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<MovieMovieIdRatingscombinedGet200Response>(
"""{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" +
""""imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""",
)
coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns
json.decodeFromString<TvTvIdRatingsGet200Response>(
"""{"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 = "Fresh",
audienceScore = 30,
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 = "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<String, String>?,
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds))
@Test
fun jellyfinItemResolvesRatingsThroughItsTmdbId() =
runTest {
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings
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
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()) }
}
}