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 de3a8f5e..94dc23c2 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,10 +371,6 @@ 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/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index fe4650a6..24d7d340 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,6 +3,8 @@ 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 @@ -121,3 +123,20 @@ 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/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt deleted file mode 100644 index 1085a8a6..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ /dev/null @@ -1,224 +0,0 @@ -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) } - }, - criticRatingFirst = true, - ) - } 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 deleted file mode 100644 index bc59f211..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ /dev/null @@ -1,232 +0,0 @@ -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() - - fun cached( - type: SeerrItemType, - 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. - * - * 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, 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 - } - } 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 30521965..282c161e 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,12 +4,11 @@ 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 @@ -235,16 +234,27 @@ fun AnnotatedString.Builder.dot() = append(" \u2022 ") fun listToDotString( strings: List, - ratings: SourceRatings?, + communityRating: Float?, + criticRating: Float?, ): AnnotatedString = buildAnnotatedString { strings.forEachIndexed { index, string -> append(string) if (index != strings.lastIndex) dot() } - ratings?.let { - appendRatings(it.criticEntries) - appendRatings(it.communityEntries) + 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") + } } } 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 d4228be0..6db89c75 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,7 +1,5 @@ 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 @@ -12,9 +10,7 @@ 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.ColorFilter -import androidx.compose.ui.graphics.ColorMatrix -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.painterResource @@ -27,13 +23,6 @@ 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 @@ -59,14 +48,11 @@ fun QuickDetails( if (DisplayToggle.OFFICIAL_RATING in enabled) { QuickDetailsText(details.officialRating, 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 (DisplayToggle.COMMUNITY_RATING in enabled) { + QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap) + } + if (DisplayToggle.CRITIC_RATING in enabled) { + QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) } } if (timeRemaining != null) { @@ -98,26 +84,15 @@ 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(square) { + InlineTextContent( + Placeholder( + textStyle.fontSize, + textStyle.fontSize, + PlaceholderVerticalAlign.TextCenter, + ), + ) { Icon( imageVector = Icons.Filled.Star, tint = FilledStarColor, @@ -125,13 +100,36 @@ fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typograp modifier = Modifier.fillMaxSize(), ) }, - 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), + "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, + ) + }, ) } 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 c5686e8f..e91dfb82 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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: SourceRatings?, + rating: DiscoverRating?, 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 51a537e3..1c0915ec 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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: SourceRatings?, + rating: DiscoverRating?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, showLogo: Boolean, @@ -107,7 +107,8 @@ fun DiscoverMovieDetailsHeader( }.let { listToDotString( it, - rating, + rating?.audienceRating, + rating?.criticRating?.toFloat(), ) } } 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 658e4ffe..20cecd12 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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,11 +19,9 @@ 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 @@ -62,7 +60,6 @@ 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() { @@ -109,11 +106,11 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val rating = - ratingsService.get( - SeerrItemType.MOVIE, - item.id, - TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()), - ) + getDiscoverRating(item.id) { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id), + ) + } _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -279,7 +276,7 @@ fun canUserCancelRequest( data class DiscoverMovieState( val movie: DataLoadingState = DataLoadingState.Pending, - val rating: SourceRatings? = null, + val rating: DiscoverRating? = 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 88fa0b50..049f7610 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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: SourceRatings?, + rating: DiscoverRating?, canCancel: Boolean, seasons: List, similar: List, @@ -422,7 +422,7 @@ fun DiscoverSeriesDetailsContent( @Composable fun DiscoverSeriesDetailsHeader( series: TvDetails, - rating: SourceRatings?, + rating: DiscoverRating?, overviewOnClick: () -> Unit, showLogo: Boolean, modifier: Modifier = Modifier, @@ -466,7 +466,8 @@ fun DiscoverSeriesDetailsHeader( }.let { listToDotString( it, - rating, + rating?.audienceRating, + rating?.criticRating?.toFloat(), ) } } 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 b4d6f37f..ec500351 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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,10 +18,8 @@ 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 @@ -62,7 +60,6 @@ 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() { @@ -112,11 +109,9 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { val rating = - ratingsService.get( - SeerrItemType.TV, - item.id, - TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()), - ) + getDiscoverRating(item.id) { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -421,7 +416,7 @@ class DiscoverSeriesViewModel data class DiscoverSeriesState( val tvSeries: DataLoadingState = DataLoadingState.Pending, - val rating: SourceRatings? = null, + val rating: DiscoverRating? = 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 cc89159f..3e625d60 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,16 +1,39 @@ 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/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index bf8d698f..ef0d186e 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,7 +339,6 @@ 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 13ee747c..ee8f74f0 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,8 +20,6 @@ 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 @@ -43,10 +41,8 @@ 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( @@ -68,7 +64,7 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - quickDetails, + movie.ui.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 fc9e7ad6..939d1331 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,7 +12,6 @@ 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 @@ -20,7 +19,6 @@ 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 @@ -59,7 +57,6 @@ 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, @@ -103,8 +100,6 @@ class MovieViewModel return item } - private var ratingsJob: Job? = null - fun init(): Job = viewModelScope.launchDefault { val movie = @@ -129,7 +124,6 @@ class MovieViewModel chosenStreams = chosenStreams, trailers = remoteTrailers, chapters = chapters, - sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie), ) } backdropService.submit(movie) @@ -158,14 +152,6 @@ 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 { @@ -319,8 +305,6 @@ 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 60419e83..f42961ad 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,10 +44,8 @@ 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 @@ -255,7 +253,6 @@ fun SeriesDetails( } }, discovered = state.discovered, - sourceRatings = state.sourceRatings, onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, @@ -333,7 +330,6 @@ fun SeriesDetailsContent( extras: List, people: List, discovered: List, - sourceRatings: SourceRatings?, played: Boolean, favorite: Boolean, canDelete: Boolean, @@ -381,7 +377,6 @@ fun SeriesDetailsContent( showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, bringIntoViewRequester = bringIntoViewRequester, - sourceRatings = sourceRatings, modifier = Modifier .fillMaxWidth() @@ -659,11 +654,9 @@ 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, @@ -681,7 +674,7 @@ fun SeriesDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - quickDetails, + series.ui.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 caaa1fb5..2b92f71b 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,7 +12,6 @@ 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 @@ -21,7 +20,6 @@ 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 @@ -102,7 +100,6 @@ 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?, @@ -221,7 +218,6 @@ class SeriesViewModel seasons = seasons, episodes = episodes, trailers = remoteTrailers, - sourceRatings = ratingsService.cachedFor(series), ) } @@ -255,11 +251,6 @@ 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) } @@ -904,7 +895,5 @@ 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, ) 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 4bdc42ba..cd7c6a27 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,12 +73,11 @@ 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, SourceRatings>>(mapOf()) + val rating = MutableStateFlow>(mapOf()) init { viewModelScope.launchIO { @@ -247,23 +246,50 @@ class SeerrDiscoverViewModel } } - private var ratingJob: Job? = null + private val ratingCache = + CacheBuilder + .newBuilder() + .maximumSize(100) + .build() - /** - * Fetch the ratings for the focused item, cancelling any previous request that is still running - */ + // TODO this is not very efficient fun fetchRating( id: Int, type: SeerrItemType, ) { - 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) } + viewModelScope.launchIO { + val cachedResult = ratingCache.getIfPresent(id) + if (cachedResult != null) { + return@launchIO } + 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() + } + } } } @@ -398,8 +424,14 @@ fun SeerrDiscoverPage( ?.toString() ?.let(::add) }.let { - val rating = ratingMap[focusedItem.item.type to focusedItem.id] - QuickDetailsData(listToDotString(it, rating)) + val rating = focusedItem.id.let { ratingMap[it] } + val str = + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + QuickDetailsData(str) } } } diff --git a/app/src/main/res/drawable/ic_rating_imdb.xml b/app/src/main/res/drawable/ic_rating_imdb.xml deleted file mode 100644 index 6725d42f..00000000 --- a/app/src/main/res/drawable/ic_rating_imdb.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_rating_tmdb.xml b/app/src/main/res/drawable/ic_rating_tmdb.xml deleted file mode 100644 index 31c0be7d..00000000 --- a/app/src/main/res/drawable/ic_rating_tmdb.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_rt_spilled.xml b/app/src/main/res/drawable/ic_rt_spilled.xml deleted file mode 100644 index fea81f97..00000000 --- a/app/src/main/res/drawable/ic_rt_spilled.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_rt_upright.xml b/app/src/main/res/drawable/ic_rt_upright.xml deleted file mode 100644 index e7166b39..00000000 --- a/app/src/main/res/drawable/ic_rt_upright.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d174ccec..354f3298 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 - example: 'Certified Fresh' + enum: ['Rotten', 'Fresh', 'Certified Fresh'] audienceScore: type: integer example: 65 audienceRating: type: string - example: 'Upright' + enum: ['Spilled', 'Upright'] /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined @@ -6573,13 +6573,13 @@ paths: example: 85 criticsRating: type: string - example: 'Certified Fresh' + enum: ['Rotten', 'Fresh', 'Certified Fresh'] audienceScore: type: integer example: 65 audienceRating: type: string - example: 'Upright' + enum: ['Spilled', 'Upright'] imdb: type: object properties: @@ -6590,12 +6590,8 @@ paths: type: string example: 'https://www.imdb.com/title/tt0480249' criticsScore: - type: number - example: 8.8 - criticsScoreCount: type: integer - format: int64 - example: 2500000 + example: 6.5 /tv/{tvId}: get: summary: Get TV details @@ -6777,13 +6773,7 @@ paths: example: 85 criticsRating: type: string - example: 'Certified Fresh' - audienceScore: - type: integer - example: 65 - audienceRating: - type: string - example: 'Upright' + enum: ['Rotten', 'Fresh'] /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 deleted file mode 100644 index 41fee583..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -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%")) - } -} 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 deleted file mode 100644 index 2be93089..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt +++ /dev/null @@ -1,266 +0,0 @@ -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() - 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 = "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( - """{"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 = "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?, - ) = 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()) } - } -}