From 2ccce98dc1c85a9ed533ba686ce43a5221daf25f Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 21:04:39 +0100 Subject: [PATCH 1/2] Show PWA source ratings on every surface through one shared rating row --- .../damontecres/wholphin/MainActivity.kt | 10 +- .../wholphin/data/model/BaseItem.kt | 28 -- .../wholphin/data/model/SourceRatings.kt | 96 +++--- .../wholphin/services/LatestNextUpService.kt | 6 +- .../wholphin/services/SeerrRatingsService.kt | 151 ++++++++-- .../damontecres/wholphin/ui/Formatting.kt | 11 +- .../damontecres/wholphin/ui/UiConstants.kt | 10 + .../ui/components/CollectionFolderList.kt | 2 +- .../wholphin/ui/components/QuickDetails.kt | 106 ++----- .../wholphin/ui/components/Rating.kt | 277 +----------------- .../ui/components/SourceRatingsRow.kt | 147 ++++++++++ .../discover/DiscoverMovieDetailsHeader.kt | 12 +- .../detail/discover/DiscoverSeriesDetails.kt | 12 +- .../ui/detail/episode/EpisodeDetailsHeader.kt | 2 +- .../wholphin/ui/detail/movie/MovieDetails.kt | 1 - .../ui/detail/movie/MovieDetailsHeader.kt | 6 +- .../ui/detail/movie/MovieViewModel.kt | 15 - .../ui/detail/series/FocusedEpisodeHeader.kt | 4 +- .../ui/detail/series/SeriesDetails.kt | 9 +- .../ui/detail/series/SeriesViewModel.kt | 10 - .../wholphin/ui/discover/SeerrDiscoverPage.kt | 7 +- .../damontecres/wholphin/ui/main/HomePage.kt | 6 +- .../wholphin/data/model/SourceRatingsTest.kt | 71 +++-- .../services/SeerrRatingsItemsTest.kt | 268 +++++++++++++++++ .../services/SeerrRatingsServiceTest.kt | 6 +- .../ui/components/SourceRatingsRowTest.kt | 239 +++++++++++++++ 26 files changed, 944 insertions(+), 568 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt create mode 100644 app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index a872d3be..175789e8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -45,6 +45,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService import com.github.damontecres.wholphin.services.ScreensaverService +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.ServerEventListener import com.github.damontecres.wholphin.services.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager @@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.collectLatestIn import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel @@ -119,6 +121,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var imageUrlService: ImageUrlService + @Inject + lateinit var seerrRatingsService: SeerrRatingsService + @Inject lateinit var refreshRateService: RefreshRateService @@ -263,7 +268,10 @@ class MainActivity : AppCompatActivity() { LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { + CompositionLocalProvider( + LocalImageUrlService provides imageUrlService, + LocalSeerrRatingsService provides seerrRatingsService, + ) { WholphinTheme( true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, 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..4b38d398 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 @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.data.model -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import androidx.compose.ui.text.AnnotatedString @@ -32,7 +31,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.CollectionType import org.jellyfin.sdk.model.extensions.ticks -import java.util.Locale import java.util.UUID import kotlin.time.Duration @@ -234,26 +232,6 @@ data class BaseItem( append(it) } }, - communityRating = - data.communityRating?.let { - buildAnnotatedString { - dot() - append(String.format(Locale.getDefault(), "%.1f", it)) - appendInlineContent(id = "star") - } - }, - criticRating = - data.criticRating?.let { - buildAnnotatedString { - dot() - append("${it.toInt()}%") - if (it >= 60f) { - appendInlineContent(id = "fresh") - } else { - appendInlineContent(id = "rotten") - } - } - }, ), ) @@ -369,12 +347,6 @@ data class BaseItemUi( data class QuickDetailsData( val basic: AnnotatedString, val officialRating: AnnotatedString? = null, - val criticRating: AnnotatedString? = null, - val communityRating: AnnotatedString? = null, - /** - * Show [criticRating] (Rotten Tomatoes) before [communityRating] (IMDb/TMDB) like the PWA, instead of the Jellyfin order - */ - val criticRatingFirst: Boolean = false, ) /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt index 1085a8a6..f6c6e5e2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -1,10 +1,6 @@ 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 /** @@ -43,16 +39,27 @@ data class RtScore( ) /** - * One displayable rating + * The badge drawn in front of a rating's number. One per PWA icon. + */ +enum class RatingBadge { + RT_FRESH, + RT_ROTTEN, + RT_UPRIGHT, + RT_SPILLED, + + /** Rotten Tomatoes without a recognised label, drawn muted */ + RT_UNKNOWN, + IMDB, + TMDB, +} + +/** + * One displayable rating: a badge and its number */ @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 + val badge: RatingBadge data class RottenTomatoes( val score: RtScore, @@ -60,14 +67,14 @@ sealed interface RatingEntry { val critics: Boolean, ) : RatingEntry { override val text: String get() = "${score.percent}%" - override val inlineContentId: String + override val badge: RatingBadge 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 + RtMark.FRESH -> RatingBadge.RT_FRESH + RtMark.ROTTEN -> RatingBadge.RT_ROTTEN + RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT + RtMark.SPILLED -> RatingBadge.RT_SPILLED + RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN } } @@ -75,25 +82,17 @@ sealed interface RatingEntry { val score: Double, ) : RatingEntry { override val text: String get() = formatScore(score) - override val inlineContentId: String get() = INLINE_IMDB + override val badge: RatingBadge get() = RatingBadge.IMDB } data class Tmdb( val score: Double, ) : RatingEntry { override val text: String get() = formatScore(score) - override val inlineContentId: String get() = INLINE_TMDB + override val badge: RatingBadge get() = RatingBadge.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) /** @@ -131,6 +130,16 @@ data class SourceRatings( tmdb?.let { RatingEntry.Tmdb(it) }, ) + /** + * The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB). + * + * Rotten Tomatoes is controlled by the critic rating display setting and IMDb/TMDB by the community rating setting. + */ + fun visibleEntries( + community: Boolean, + critic: Boolean, + ): List = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList()) + companion object { const val MAX_PERCENT = 100.0 const val MAX_SCORE = 10.0 @@ -187,38 +196,3 @@ data class SourceRatings( } } } - -/** - * 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/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 4ce6583f..dbaa219e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -6,7 +6,7 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.data.model.BaseItem -import com.github.damontecres.wholphin.ui.SlimItemFields +import com.github.damontecres.wholphin.ui.HomeSlimItemFields import com.github.damontecres.wholphin.util.LocalDateTimeSerializer import com.github.damontecres.wholphin.util.WholphinDispatchers import kotlinx.coroutines.async @@ -58,7 +58,7 @@ class LatestNextUpService val request = GetResumeItemsRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, limit = limit, mediaTypes = listOf(MediaType.VIDEO), excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, @@ -90,7 +90,7 @@ class LatestNextUpService val request = GetNextUpRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, imageTypeLimit = 1, parentId = null, limit = limit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt index bc59f211..20942c78 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -9,12 +9,19 @@ 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.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber +import java.util.Optional +import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -28,7 +35,22 @@ data class TmdbVote( ) /** - * Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses + * The Seerr title whose ratings are shown for a Jellyfin item + */ +data class RatingsTarget( + val type: SeerrItemType, + val tmdbId: Int, +) + +/** + * Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings + * reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings]. + * + * Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else: + * - movies show their own ratings + * - series show their own ratings + * - seasons and episodes show their series' ratings, as the PWA does for the title page an episode belongs to + * - everything else (collections, music, photos, programs, ...) has none * * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from * `voteAverage`/`voteCount` and is only requested when no other source has a score. @@ -41,6 +63,7 @@ class SeerrRatingsService @Inject constructor( private val seerrService: SeerrService, + private val api: ApiClient, ) { private data class Key( val type: SeerrItemType, @@ -54,43 +77,118 @@ class SeerrRatingsService .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) .build() + /** + * The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page + * don't carry their series' provider ids, so this is how they are found. + */ + private val tmdbIds = + 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 Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings. + * An episode's or season's own provider ids are for the episode or season, so those use the series. + */ + private fun ownerId(item: BaseItem): UUID? = + when (item.type) { + BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id + BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId + else -> null + } + + /** + * The TMDB id if it is known without a request: the item's own provider ids or an earlier lookup. + * Present but empty means the item definitely has no TMDB id, null means unknown. + */ + private fun knownTmdbId( + item: BaseItem, + ownerId: UUID, + ): Optional? = + item.data.providerIds + ?.takeIf { ownerId == item.id && it.isNotEmpty() } + ?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) } + ?: tmdbIds.getIfPresent(ownerId) + + private fun type(item: BaseItem): SeerrItemType? = + when (item.type) { + BaseItemKind.MOVIE -> SeerrItemType.MOVIE + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV + else -> null + } + + private fun knownTarget(item: BaseItem): RatingsTarget? { + val type = type(item) ?: return null + val owner = ownerId(item) ?: return null + return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) } } /** - * 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 + * Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand. + * Null if the item has no ratings, no TMDB id or the lookup failed. */ - fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) } + suspend fun resolveTarget(item: BaseItem): RatingsTarget? { + val type = type(item) ?: return null + val owner = ownerId(item) ?: return null + val tmdb = + knownTmdbId(item, owner) ?: try { + Optional + .ofNullable( + api.userLibraryApi + .getItem(owner) + .content.providerIds + ?.get("Tmdb") + ?.toIntOrNull(), + ).also { tmdbIds.put(owner, it) } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.w(ex, "Error fetching provider ids for %s", owner) + return null + } + return tmdb.orElse(null)?.let { RatingsTarget(type, it) } + } /** - * 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. + * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty */ - 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 } + fun cachedFor(item: BaseItem): SourceRatings? = knownTarget(item)?.let { cached(it.type, it.tmdbId) } + + /** + * Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established + * asynchronously after login. + * + * Already cached ratings are emitted at once. Otherwise nothing is requested until [debounceMillis] has passed, so + * collecting for each focused item while scrolling quickly only asks Seerr about the item the user stops on: + * collecting is cancelled for the previous item, which cancels the wait or the request in progress. + * + * Emits null while Seerr is not active, the item has no ratings (see above) or no TMDB id, or the lookup failed. + */ + fun forItem( + item: BaseItem?, + debounceMillis: Long = DEBOUNCE_MILLIS, + ): Flow { + if (item == null || type(item) == null) return flowOf(null) + cachedFor(item)?.let { return flowOf(it) } + val owner = ownerId(item) ?: return flowOf(null) + if (knownTmdbId(item, owner)?.isPresent == false) return flowOf(null) + return flow { + delay(debounceMillis) + emitAll( + seerrService.active + .distinctUntilChanged() + .mapLatest { active -> + if (active) resolveTarget(item)?.let { get(it.type, it.tmdbId) } else null + }, + ) + } } /** @@ -228,5 +326,6 @@ class SeerrRatingsService companion object { private const val CACHE_SIZE = 300L private const val CACHE_HOURS = 6L + const val DEBOUNCE_MILLIS = 350L } } 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..f774a0da 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 @@ -8,8 +8,6 @@ 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 @@ -233,19 +231,12 @@ val MediaSegmentType.skipStringRes: Int fun AnnotatedString.Builder.dot() = append(" \u2022 ") -fun listToDotString( - strings: List, - ratings: SourceRatings?, -): AnnotatedString = +fun listToDotString(strings: List): AnnotatedString = buildAnnotatedString { strings.forEachIndexed { index, string -> append(string) if (index != strings.lastIndex) dot() } - ratings?.let { - appendRatings(it.criticEntries) - appendRatings(it.communityEntries) - } } @get:StringRes diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 2e57b407..786db3d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -75,12 +75,22 @@ val SlimItemFields = */ val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO +/** + * [ItemFields] for the home page's rows. [ItemFields.PROVIDER_IDS] is needed so a focused movie or series has its TMDB id + * for [com.github.damontecres.wholphin.services.SeerrRatingsService] without another request. + */ val HomeItemFields = listOf( ItemFields.OVERVIEW, ItemFields.CAN_DELETE, + ItemFields.PROVIDER_IDS, ) +/** + * [SlimItemFields] for the home page's continue watching & next up rows, see [HomeItemFields] + */ +val HomeSlimItemFields = SlimItemFields + ItemFields.PROVIDER_IDS + val PhotoItemFields = DetailItemFields + listOf( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index a048ed94..967ddb07 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -250,7 +250,7 @@ fun CollectionFolderListDetails( ) item?.let { - QuickDetails(item.ui.quickDetails, null) + QuickDetails(item, null) } OverviewText( 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..902ce814 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,41 +1,24 @@ 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 -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable 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.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.Placeholder -import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString -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.BaseItem import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.DisplayToggle +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.ui.dot import com.github.damontecres.wholphin.ui.formatTime import com.github.damontecres.wholphin.ui.util.LocalClock @@ -43,6 +26,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import org.jellyfin.sdk.model.DateTime import kotlin.time.Duration +/** + * The line of details under a title: year, runtime, etc, then the ratings, then when it ends. + * + * Ratings are only drawn for [ratings], never from the Jellyfin item. Pass the result of [rememberSourceRatings] or null + * for surfaces without ratings. + */ @Composable fun QuickDetails( details: QuickDetailsData?, @@ -50,24 +39,16 @@ fun QuickDetails( modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, endsAt: DateTime? = null, + ratings: SourceRatings? = null, ) { val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles - val inlineContentMap = rememberQuickDetailsContentMap(textStyle) Row(modifier = modifier) { if (details != null) { - QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap) + QuickDetailsText(details.basic, Modifier, textStyle) 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) + QuickDetailsText(details.officialRating, Modifier, textStyle) } + SourceRatingsRow(ratings, Modifier, textStyle) } if (timeRemaining != null) { TimeRemaining(timeRemaining, textStyle = textStyle) @@ -77,64 +58,41 @@ fun QuickDetails( } } +/** + * [QuickDetails] for a Jellyfin item with the ratings the PWA shows for it, if any (see [SeerrRatingsService]). + */ +@Composable +fun QuickDetails( + item: BaseItem, + timeRemaining: Duration?, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, + endsAt: DateTime? = null, +) = QuickDetails( + details = item.ui.quickDetails, + timeRemaining = timeRemaining, + modifier = modifier, + textStyle = textStyle, + endsAt = endsAt, + ratings = rememberSourceRatings(item), +) + @NonRestartableComposable @Composable fun QuickDetailsText( str: AnnotatedString?, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, - inlineContentMap: Map = rememberQuickDetailsContentMap(textStyle), ) = str?.let { Text( text = str, color = MaterialTheme.colorScheme.onSurface, style = textStyle, - inlineContent = inlineContentMap, maxLines = 1, modifier = modifier, ) } -@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) { - Icon( - imageVector = Icons.Filled.Star, - tint = FilledStarColor, - contentDescription = null, - 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), - ) - } - @Composable fun TimeRemaining( remaining: Duration, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index c52fb52f..460eda05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -1,93 +1,29 @@ package com.github.damontecres.wholphin.ui.components -import androidx.compose.foundation.background -import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Star import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithCache -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.tv.material3.Icon -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalTextStyle -import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import com.github.damontecres.wholphin.R -import com.github.damontecres.wholphin.ui.AppColors -import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.isNotNullOrBlank -import com.github.damontecres.wholphin.ui.playOnClickSound -import com.github.damontecres.wholphin.ui.playSoundOnFocus -import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.tryRequestFocus -import java.util.Locale - -enum class StarRatingPrecision { - FULL, - HALF, - QUARTER, - ; - - companion object { - fun fromFloat(value: Float): StarRatingPrecision = - if (value <= .25f) { - QUARTER - } else if (value <= .5f) { - HALF - } else { - FULL - } - } -} val FilledStarColor = Color(0xFFFFC700) val EmptyStarColor = Color(0xFF5D4A0A) -val ratingBarHeight: Dp = 32.dp - -@Composable -fun SimpleStarRating( - communityRating: Float?, - modifier: Modifier = Modifier, -) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - SimpleStarRating( - text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) }, - modifier = modifier, - ) -} - +/** + * A minimum rating such as "7+" with a star, for the rating filter menus. + * + * This is not for showing a title's rating, see [SourceRatingsRow] for that. + */ @Composable fun SimpleStarRating( text: String?, @@ -115,206 +51,3 @@ fun SimpleStarRating( } } } - -@Composable -fun TomatoRating( - rating: Float?, - modifier: Modifier = Modifier, - threshold: Float = 60f, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier, - ) { - if (rating != null) { - CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - Text( - text = rating.toInt().toString() + "%", - maxLines = 1, - modifier = Modifier, - ) - } - val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() * .8f } - Icon( - painter = - painterResource( - if (rating >= threshold) { - R.drawable.ic_rotten_tomatoes_fresh - } else { - R.drawable.ic_rotten_tomatoes_rotten - }, - ), - contentDescription = null, - modifier = Modifier.height(height), - tint = Color.Unspecified, - ) - } - } -} - -/** - * Shows a rating out of 5 stars - */ -@Composable -fun StarRating( - rating100: Int, - onRatingChange: (Int) -> Unit, - precision: StarRatingPrecision, - enabled: Boolean, - playSoundOnFocus: Boolean, - modifier: Modifier = Modifier, - bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background, -) { - val context = LocalContext.current - var tempRating by remember(rating100) { mutableIntStateOf(rating100) } - val percentage = (if (enabled) tempRating else rating100) / 100f - val focusRequesters = remember { List(5) { FocusRequester() } } - Box( - modifier = - modifier - .clip(RoundedCornerShape(16.dp)) - .background(bgColor), - ) { - LazyRow( - modifier = - Modifier - .selectableGroup() - .padding(4.dp) - .drawWithCache { - onDrawWithContent { - drawContent() - if (percentage in 0f..<1f) { - drawRect( - color = bgColor, - topLeft = Offset(size.width * percentage, 0f), - blendMode = BlendMode.SrcAtop, - ) - } - } - }.focusGroup() - .focusProperties { - onEnter = { - val index = - if (rating100 <= 20) { - 0 - } else if (rating100 <= 40) { - 1 - } else if (rating100 <= 60) { - 2 - } else if (rating100 <= 80) { - 3 - } else { - 4 - } - focusRequesters[index].tryRequestFocus() - } - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly, - ) { - for (i in 1..5) { - item { - val isRated = (if (enabled) tempRating else rating100) >= (i * 20) - val icon = Icons.Filled.Star - var focused by remember { mutableStateOf(false) } - - val focusedColor = - if (focused) { - MaterialTheme.colorScheme.border - } else { - Color.Unspecified - } - Box( - modifier = - Modifier - .clip(RoundedCornerShape(16.dp)) - .background(focusedColor), - ) { - Icon( - imageVector = icon, - tint = FilledStarColor, - contentDescription = null, - modifier = - if (enabled) { - Modifier - .onFocusChanged { - focused = it.isFocused - if (it.isFocused) { - tempRating = i * 20 - } else { - tempRating = rating100 - } - }.playSoundOnFocus(playSoundOnFocus) - .focusRequester(focusRequesters[i - 1]) - .focusProperties { - left = - if (i == 1) focusRequesters.last() else FocusRequester.Default - right = - if (i == 5) focusRequesters.first() else FocusRequester.Default - }.selectable( - selected = isRated, - onClick = { - if (playSoundOnFocus) playOnClickSound(context) - val newRating100 = - when (precision) { - StarRatingPrecision.FULL -> { - if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20 - } - - StarRatingPrecision.HALF -> { - if (rating100 > i * 20) { - i * 20 - } else if (rating100 == i * 20) { - i * 20 - 10 - } else if (rating100 == i * 20 - 10) { - (i - 1) * 20 - } else if (i == 1 && rating100 == 0) { - 20 - } else if (i == 1 && rating100 > 10) { - 10 - } else if (i == 1 && rating100 <= 10) { - 0 - } else { - (i) * 20 - } - } - - StarRatingPrecision.QUARTER -> { - // TODO - null - } - } - if (newRating100 != null) { - tempRating = newRating100 - onRatingChange(newRating100) - } - }, - ) - } else { - Modifier - }.fillMaxHeight() - .aspectRatio(1f), - ) - } - } - } - } - } -} - -@PreviewTvSpec -@Composable -private fun SimpleStarRatingPreview() { - WholphinTheme { - Column { - SimpleStarRating(7.5f, Modifier.height(32.dp)) - TomatoRating(75f) - SimpleStarRating(7.5f, Modifier.height(20.dp)) - SimpleStarRating("", Modifier.height(32.dp)) - TomatoRating(75f) - TomatoRating(40f) - TomatoRating(3f) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt new file mode 100644 index 00000000..e2552ace --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt @@ -0,0 +1,147 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +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.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.RatingBadge +import com.github.damontecres.wholphin.data.model.RatingEntry +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.preferences.DisplayToggle +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.ui.dot +import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization + +/** + * Provides the [SeerrRatingsService] to [rememberSourceRatings] + */ +val LocalSeerrRatingsService = + staticCompositionLocalOf { throw IllegalStateException("LocalSeerrRatingsService not set") } + +/** + * The ratings for an item from the same sources as the PWA. This is the only data path for ratings in the UI, every + * screen that shows ratings calls this and draws the result with [SourceRatingsRow] (usually via [QuickDetails]). + * + * Which items have ratings is decided by [SeerrRatingsService]. Returns null while loading, or if there are none, and + * never the previous item's ratings. + */ +@Composable +fun rememberSourceRatings(item: BaseItem?): SourceRatings? { + val service = LocalSeerrRatingsService.current + val key = item?.id + var ratings by remember(key) { mutableStateOf(item?.let { service.cachedFor(it) }) } + LaunchedEffect(key) { + // Leaving the composition or changing the item cancels this collection, and so a Seerr request that is still waiting or running + service.forItem(item).collect { ratings = it } + } + return ratings +} + +@DrawableRes +fun RatingBadge.drawable(): Int = + when (this) { + RatingBadge.RT_FRESH -> R.drawable.ic_rotten_tomatoes_fresh + RatingBadge.RT_ROTTEN -> R.drawable.ic_rotten_tomatoes_rotten + RatingBadge.RT_UPRIGHT -> R.drawable.ic_rt_upright + RatingBadge.RT_SPILLED -> R.drawable.ic_rt_spilled + RatingBadge.RT_UNKNOWN -> R.drawable.ic_rotten_tomatoes_fresh + RatingBadge.IMDB -> R.drawable.ic_rating_imdb + RatingBadge.TMDB -> R.drawable.ic_rating_tmdb + } + +/** + * The badges for the ratings as inline text content. IMDb and TMDB are wordmarks, wider than tall like in the PWA. + */ +@Composable +private fun rememberBadges(textStyle: TextStyle): Map = + remember(textStyle) { + val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) + val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter) + RatingBadge.entries.associate { badge -> + val muted = badge == RatingBadge.RT_UNKNOWN + val placeholder = if (badge == RatingBadge.IMDB || badge == RatingBadge.TMDB) wide else square + badge.name to + InlineTextContent(placeholder) { + Image( + painter = painterResource(badge.drawable()), + 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(), + ) + } + } + } + +/** + * The text for the ratings: each as a badge and its number, separated by dots + * + * @param leadingDot whether a dot is put in front of the first rating because something precedes it + */ +fun ratingsText( + entries: List, + leadingDot: Boolean, +): AnnotatedString = + buildAnnotatedString { + entries.forEachIndexed { index, entry -> + if (index > 0 || leadingDot) dot() + appendInlineContent(entry.badge.name) + append(" ") + append(entry.text) + } + } + +/** + * The one way ratings are drawn: badge and number pairs from [SourceRatings] in the PWA's order, Rotten Tomatoes then IMDb or TMDB. + * + * Respects the community rating (IMDb/TMDB) and critic rating (Rotten Tomatoes) display settings. Draws nothing if + * there is nothing to show. + */ +@Composable +fun SourceRatingsRow( + ratings: SourceRatings?, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.titleSmall, + leadingDot: Boolean = true, +) { + if (ratings == null) return + val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles + val community = DisplayToggle.COMMUNITY_RATING in enabled + val critic = DisplayToggle.CRITIC_RATING in enabled + val text = + remember(ratings, community, critic, leadingDot) { + ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot) } + } ?: return + val badges = rememberBadges(textStyle) + Text( + text = text, + color = MaterialTheme.colorScheme.onSurface, + style = textStyle, + inlineContent = badges, + maxLines = 1, + modifier = modifier, + ) +} 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..34bf4657 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,6 +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.QuickDetailsData import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.jellyfinId @@ -27,7 +28,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader( ) { val resources = LocalResources.current val details = - remember(movie, rating, resources) { + remember(movie, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime @@ -105,14 +106,11 @@ fun DiscoverMovieDetailsHeader( ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { - listToDotString( - it, - rating, - ) + QuickDetailsData(listToDotString(it)) } } - QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) + QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) movie.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } 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..ef96bb7e 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,6 +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.QuickDetailsData import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.SeerrPermission @@ -55,7 +56,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.OverviewText -import com.github.damontecres.wholphin.ui.components.QuickDetailsText +import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -453,7 +454,7 @@ fun DiscoverSeriesDetailsHeader( ) { val resources = LocalResources.current val details = - remember(series, rating, resources) { + remember(series, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -464,14 +465,11 @@ fun DiscoverSeriesDetailsHeader( ?.let { add(resources.formatDuration(it)) } // TODO }.let { - listToDotString( - it, - rating, - ) + QuickDetailsData(listToDotString(it)) } } - QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) + QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) series.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index e8872180..3c341ff2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -55,7 +55,7 @@ fun EpisodeDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime) + QuickDetails(ep, ep.timeRemainingOrRuntime) VideoStreamDetails( chosenStreams = chosenStreams, 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..6dbbf965 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, 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..db6a6f6a 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 { @@ -320,7 +306,6 @@ data class MovieState( 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/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index b63f1cbc..be7795f4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -35,10 +35,10 @@ fun FocusedEpisodeHeader( ) { EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) - ep?.ui?.quickDetails?.let { + ep?.let { QuickDetails( it, - ep.timeRemainingOrRuntime, + it.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) } 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..0308468e 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, 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..32f1d484 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) } @@ -905,6 +896,5 @@ data class SeriesState( 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..62075882 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 @@ -389,7 +389,7 @@ fun SeerrDiscoverPage( modifier = modifier, ) { val details = - remember(focusedItem, ratingMap) { + remember(focusedItem) { (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> buildList { focusedItem.item @@ -398,17 +398,18 @@ fun SeerrDiscoverPage( ?.toString() ?.let(::add) }.let { - val rating = ratingMap[focusedItem.item.type to focusedItem.id] - QuickDetailsData(listToDotString(it, rating)) + QuickDetailsData(listToDotString(it)) } } } + val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] } HomePageHeader( title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, overviewTwoLines = false, quickDetails = details, + ratings = focusedRatings, timeRemaining = null, endsAt = null, showLogo = preferences.appPreferences.interfacePreferences.showLogos, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index bf24ae00..26cc96d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.cards.BannerCard @@ -72,6 +73,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage import com.github.damontecres.wholphin.ui.components.QuickDetails import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.components.rememberLogoUrl +import com.github.damontecres.wholphin.ui.components.rememberSourceRatings import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -571,6 +573,7 @@ fun HomePageHeader( overview = item?.data?.overview, overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails, + ratings = rememberSourceRatings(item), timeRemaining = item?.timeRemainingOrRuntime, endsAt = item?.data?.endDate, showLogo = showLogo, @@ -586,6 +589,7 @@ fun HomePageHeader( overview: String?, overviewTwoLines: Boolean, quickDetails: QuickDetailsData?, + ratings: SourceRatings?, timeRemaining: Duration?, endsAt: DateTime?, showLogo: Boolean, @@ -611,7 +615,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) val overviewModifier = Modifier .padding(0.dp) diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt index 41fee583..e2471275 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -1,10 +1,8 @@ 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 @@ -104,44 +102,59 @@ class SourceRatingsTest { 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(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge }) assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) - assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId }) + assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge }) } - private val jellyfinDetails = - QuickDetailsData( - basic = AnnotatedString("2020"), - communityRating = AnnotatedString("7.8 jellyfin"), - criticRating = AnnotatedString("59% jellyfin"), + @Test + fun everyRtMarkHasItsOwnBadge() { + val badges = + listOf( + RtMark.FRESH to RatingBadge.RT_FRESH, + RtMark.ROTTEN to RatingBadge.RT_ROTTEN, + RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT, + RtMark.SPILLED to RatingBadge.RT_SPILLED, + RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN, + ) + badges.forEach { (mark, badge) -> + assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge) + } + } + + private val movie = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 59, + rtAudienceLabel = "Spilled", + imdbScore = 8.8, + imdbVotes = 10, ) @Test - fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() { - assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null)) - assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings())) + fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() { + // Rotten Tomatoes (critic rating setting) before IMDb (community rating setting) + assertEquals( + listOf("96%", "59%", "8.8"), + movie.visibleEntries(community = true, critic = true).map { it.text }, + ) + assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text }) + assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text }) + assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty()) } @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) + fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() { + val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true) + assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) + assertEquals(listOf("7.6"), entries.map { it.text }) } @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%")) + fun noEntriesWithoutRatings() { + assertTrue(SourceRatings().isEmpty) + assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty()) + assertFalse(movie.isEmpty) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt new file mode 100644 index 00000000..72baa65c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt @@ -0,0 +1,268 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +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.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +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.ui.successResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +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.Test +import java.io.IOException +import java.util.UUID + +/** + * Which Jellyfin items have ratings, how their TMDB id is found, and when Seerr is asked + */ +class SeerrRatingsItemsTest { + private val moviesApi = mockk() + private val tvApi = mockk() + private val seerrApi = + mockk { + every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi + every { tvApi } returns this@SeerrRatingsItemsTest.tvApi + } + private val active = MutableStateFlow(true) + private val seerrService = + mockk { + every { api } returns seerrApi + every { this@mockk.active } returns this@SeerrRatingsItemsTest.active + } + private val userLibraryApi = mockk() + private val jellyfin = + mockk { + every { userLibraryApi } returns this@SeerrRatingsItemsTest.userLibraryApi + } + private val service = SeerrRatingsService(seerrService, jellyfin) + + private val seriesId = UUID.randomUUID() + private val seriesTmdb = 1396 + + private val rtOnly = MovieMovieIdRatingscombinedGet200Response(rt = MovieMovieIdRatingsGet200Response(criticsScore = 90, criticsRating = "Fresh")) + private val tvRatings = TvTvIdRatingsGet200Response(criticsScore = 80, criticsRating = "Fresh") + + private fun item( + type: BaseItemKind, + providerIds: Map? = null, + seriesId: UUID? = null, + ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds, seriesId = seriesId)) + + private fun jellyfinHasSeries(tmdb: Int? = seriesTmdb) { + coEvery { userLibraryApi.getItem(seriesId) } returns + successResponse( + BaseItemDto( + id = seriesId, + type = BaseItemKind.SERIES, + providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) } ?: mapOf("Tvdb" to "1"), + ), + ) + } + + @Test + fun moviesAndSeriesUseTheirOwnProviderIdsWithoutAskingJellyfin() = + runTest { + assertEquals( + RatingsTarget(SeerrItemType.MOVIE, 11), + service.resolveTarget(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "11"))), + ) + assertEquals( + RatingsTarget(SeerrItemType.TV, 22), + service.resolveTarget(item(BaseItemKind.SERIES, mapOf("Tmdb" to "22"))), + ) + coVerify(exactly = 0) { userLibraryApi.getItem(any()) } + } + + @Test + fun episodesAndSeasonsShowTheirSeriesRatingsNotTheirOwnTmdbId() = + runTest { + jellyfinHasSeries() + // The Tmdb id on an episode or season identifies the episode or season, never the series + val episode = item(BaseItemKind.EPISODE, mapOf("Tmdb" to "999999"), seriesId) + val season = item(BaseItemKind.SEASON, mapOf("Tmdb" to "888888"), seriesId) + + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode)) + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(season)) + } + + @Test + fun itemsThatArePartOfNoPwaTitleHaveNoRatingsAndNeverCallAnything() = + runTest { + val none = + listOf( + BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.AUDIO, + BaseItemKind.PHOTO, + BaseItemKind.PHOTO_ALBUM, + BaseItemKind.PROGRAM, + BaseItemKind.PLAYLIST, + BaseItemKind.VIDEO, + ) + none.forEach { + val item = item(it, mapOf("Tmdb" to "5"), seriesId) + assertNull(it.name, service.resolveTarget(item)) + assertNull(it.name, service.forItem(item).first()) + assertNull(it.name, service.cachedFor(item)) + } + coVerify(exactly = 0) { userLibraryApi.getItem(any()) } + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + coVerify(exactly = 0) { tvApi.tvTvIdRatingsGet(any()) } + } + + @Test + fun episodeWithoutASeriesHasNoRatings() = + runTest { + assertNull(service.resolveTarget(item(BaseItemKind.EPISODE, mapOf("Tmdb" to "3"), null))) + } + + @Test + fun homeItemsWithoutProviderIdsAreResolvedThroughJellyfinOnceAndCached() = + runTest { + jellyfinHasSeries() + val first = item(BaseItemKind.EPISODE, null, seriesId) + val second = item(BaseItemKind.EPISODE, null, seriesId) + + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(first)) + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(second)) + + // every other episode of the series is known without a request + coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) } + } + + @Test + fun homeMovieWithoutProviderIdsLooksItUp() = + runTest { + val movie = item(BaseItemKind.MOVIE, null) + coEvery { userLibraryApi.getItem(movie.id) } returns + successResponse(BaseItemDto(id = movie.id, type = BaseItemKind.MOVIE, providerIds = mapOf("Tmdb" to "77"))) + + assertEquals(RatingsTarget(SeerrItemType.MOVIE, 77), service.resolveTarget(movie)) + } + + @Test + fun seriesWithoutTmdbIdHasNoRatingsAndIsNotLookedUpAgain() = + runTest { + jellyfinHasSeries(tmdb = null) + val episode = item(BaseItemKind.EPISODE, null, seriesId) + + assertNull(service.resolveTarget(episode)) + assertNull(service.forItem(episode).first()) + coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) } + } + + @Test + fun failedLookupIsRetriedLater() = + runTest { + coEvery { userLibraryApi.getItem(seriesId) } throws IOException("down") + val episode = item(BaseItemKind.EPISODE, null, seriesId) + assertNull(service.resolveTarget(episode)) + + jellyfinHasSeries() + assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode)) + } + + @Test + fun episodeRatingsAreTheSeriesRatingsAndSeedLaterEpisodes() = + runTest { + jellyfinHasSeries() + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings + val episode = item(BaseItemKind.EPISODE, null, seriesId) + val other = item(BaseItemKind.EPISODE, null, seriesId) + assertNull(service.cachedFor(episode)) + + val ratings = service.forItem(episode).first() + + assertEquals(80, ratings?.rtCritics?.percent) + // moving to another episode of the same series needs no wait and no request + assertEquals(ratings, service.cachedFor(other)) + assertEquals(ratings, service.forItem(other).first()) + coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } + } + + @Test + fun scrollingPastItemsNeverAsksSeerr() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns rtOnly + val jobs = + (1..20).map { id -> + val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "$id"))).collect { } } + runCurrent() + // focus moves on before the wait has passed + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50) + job.cancel() + job + } + jobs.forEach(Job::cancel) + runCurrent() + + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + } + + @Test + fun restingOnAnItemAsksSeerrOnceAfterTheWait() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } returns rtOnly + var result: Any? = "unset" + val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "7"))).collect { result = it } } + + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 1) + runCurrent() + assertEquals("unset", result) + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + + advanceTimeBy(2) + runCurrent() + assertNotNull(result) + coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } + job.cancel() + } + + @Test + fun movingOnWhileTheRequestIsRunningCancelsIt() = + runTest { + var cancelled = false + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } coAnswers { + try { + awaitCancellation() + } finally { + cancelled = true + } + } + val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "8"))).collect { } } + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS + 1) + runCurrent() + coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } + + job.cancel() + runCurrent() + + assertEquals(true, cancelled) + assertNull(service.cached(SeerrItemType.MOVIE, 8)) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt index 2be93089..c6b008ea 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt @@ -21,6 +21,7 @@ import io.mockk.mockk import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.BaseItemKind import org.junit.Assert.assertEquals @@ -45,7 +46,7 @@ class SeerrRatingsServiceTest { every { api } returns apiClient every { active } answers { activeFlow } } - private val service = SeerrRatingsService(seerrService) + private val service = SeerrRatingsService(seerrService, mockk()) private fun movieDetails( average: Double?, @@ -253,8 +254,7 @@ class SeerrRatingsServiceTest { // 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()) + // not something that has ratings 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()) diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt new file mode 100644 index 00000000..a51c73a0 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt @@ -0,0 +1,239 @@ +package com.github.damontecres.wholphin.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +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.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.SourceRatings +import com.github.damontecres.wholphin.preferences.DisplayToggle +import com.github.damontecres.wholphin.services.SeerrRatingsService +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.successResponse +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import com.github.damontecres.wholphin.ui.util.InterfaceCustomization +import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.api.operations.UserLibraryApi +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.EnumSet +import java.util.UUID + +/** + * What every surface shows: the rating row comes only from Seerr, per the PWA's rules for the kind of item, and + * the Jellyfin rating of an item is never drawn + */ +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class SourceRatingsRowTest { + @get:Rule val rule = createComposeRule() + + private val moviesApi = mockk() + private val tvApi = mockk() + private val seerrApi = + mockk { + every { moviesApi } returns this@SourceRatingsRowTest.moviesApi + every { tvApi } returns this@SourceRatingsRowTest.tvApi + } + private val seerrService = + mockk { + every { api } returns seerrApi + every { active } returns flowOf(true) + } + private val userLibraryApi = mockk() + private val jellyfin = mockk { every { userLibraryApi } returns this@SourceRatingsRowTest.userLibraryApi } + private val service = SeerrRatingsService(seerrService, jellyfin) + + private val seriesId = UUID.randomUUID() + private val seriesTmdb = 1396 + + /** Every item carries Jellyfin ratings that must never be drawn */ + private fun item( + type: BaseItemKind, + tmdb: Int? = null, + seriesId: UUID? = null, + ) = BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = type, + name = "Title", + providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) }, + seriesId = seriesId, + communityRating = 7.8f, + criticRating = 59f, + ), + ) + + private fun movieRatings() { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns + MovieMovieIdRatingscombinedGet200Response( + rt = MovieMovieIdRatingsGet200Response(criticsScore = 96, criticsRating = "Certified Fresh", audienceScore = 90, audienceRating = "Upright"), + imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 10), + ) + } + + private fun seriesRatings() { + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns + TvTvIdRatingsGet200Response(criticsScore = 81, criticsRating = "Fresh", audienceScore = 45, audienceRating = "Spilled") + coEvery { userLibraryApi.getItem(seriesId) } returns + successResponse(BaseItemDto(id = seriesId, type = BaseItemKind.SERIES, providerIds = mapOf("Tmdb" to "$seriesTmdb"))) + } + + /** + * Shows the quick details of the items, after what a screen's wait for Seerr would have loaded + */ + private fun show( + vararg items: BaseItem, + toggles: EnumSet = EnumSet.allOf(DisplayToggle::class.java), + ) { + runBlocking { + items.forEach { service.forItem(it, 0).collect { } } + } + rule.setContent { + WholphinTheme { + CompositionLocalProvider( + LocalSeerrRatingsService provides service, + LocalInterfaceCustomization provides InterfaceCustomization(toggles), + ) { + Column { + items.forEach { QuickDetails(it, null) } + } + } + } + } + rule.waitForIdle() + } + + private fun assertShown(text: String) = rule.onNodeWithText(text, substring = true).assertExists() + + private fun assertNotShown(text: String) = rule.onNodeWithText(text, substring = true).assertDoesNotExist() + + private fun assertJellyfinRatingNotShown() { + assertNotShown("7.8") + assertNotShown("59%") + } + + @Test + fun movieShowsRottenTomatoesThenImdbAndNotItsJellyfinRating() { + movieRatings() + show(item(BaseItemKind.MOVIE, 603)) + assertShown("96%") + assertShown("90%") + assertShown("8.8") + assertJellyfinRatingNotShown() + } + + @Test + fun seriesShowsItsRatings() { + seriesRatings() + show(item(BaseItemKind.SERIES, seriesTmdb)) + assertShown("81%") + assertShown("45%") + assertJellyfinRatingNotShown() + } + + @Test + fun episodeAndSeasonShowTheirSeriesRatings() { + seriesRatings() + show(item(BaseItemKind.EPISODE, seriesId = seriesId)) + assertShown("81%") + assertJellyfinRatingNotShown() + } + + @Test + fun seasonShowsItsSeriesRatings() { + seriesRatings() + show(item(BaseItemKind.SEASON, seriesId = seriesId)) + assertShown("45%") + assertJellyfinRatingNotShown() + } + + @Test + fun surfacesWithoutAPwaEquivalentShowNoRatingNotTheJellyfinStar() { + movieRatings() + seriesRatings() + show( + *listOf( + BaseItemKind.BOX_SET, + BaseItemKind.MUSIC_ALBUM, + BaseItemKind.MUSIC_ARTIST, + BaseItemKind.PHOTO, + BaseItemKind.PROGRAM, + ).map { item(it, 603, seriesId) }.toTypedArray(), + ) + assertNotShown("96%") + assertNotShown("81%") + assertJellyfinRatingNotShown() + } + + @Test + fun titleSeerrHasNoRatingsForShowsNoRatingNotTheJellyfinStar() { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns MovieMovieIdRatingscombinedGet200Response() + coEvery { moviesApi.movieMovieIdGet(movieId = 5) } returns + mockk { + every { voteAverage } returns null + every { voteCount } returns null + } + show(item(BaseItemKind.MOVIE, 5)) + assertJellyfinRatingNotShown() + } + + @Test + fun theDisplaySettingsStillControlWhichSourcesAreDrawn() { + movieRatings() + show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING)) + assertShown("8.8") + assertNotShown("96%") + } + + @Test + fun criticSettingHidesImdb() { + movieRatings() + show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING)) + assertShown("96%") + assertNotShown("8.8") + } + + @Test + fun ratingsTextHasOneBadgeAndNumberPerEntryInOrder() { + val ratings = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 30, + rtAudienceLabel = "Spilled", + imdbScore = 8.8, + imdbVotes = 10, + ) + val entries = ratings.visibleEntries(community = true, critic = true) + val text = ratingsText(entries, leadingDot = false) + + assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%")) + assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8")) + // dots only between entries, or also in front when something precedes the ratings + assertEquals(2, text.text.split("\u2022").size - 1) + assertEquals(3, ratingsText(entries, leadingDot = true).text.split("\u2022").size - 1) + } +} -- 2.49.1 From 85365aa3c6c66ef10dffb8dfc91a8de54b21a382 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Thu, 29 Oct 2026 21:35:13 +0100 Subject: [PATCH 2/2] Show each episode's own TMDB score next to the series ratings --- .../wholphin/data/model/SourceRatings.kt | 35 +++- .../wholphin/services/SeerrRatingsService.kt | 86 +++++++++- .../ui/components/SourceRatingsRow.kt | 9 +- app/src/main/res/values/strings.xml | 1 + app/src/main/seerr/seerr-api.yml | 57 ++++++- .../wholphin/data/model/SourceRatingsTest.kt | 44 +++++ .../services/SeerrRatingsItemsTest.kt | 158 ++++++++++++++++++ .../ui/components/SourceRatingsRowTest.kt | 48 +++++- 8 files changed, 428 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt index f6c6e5e2..581a4aa2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -61,6 +61,11 @@ sealed interface RatingEntry { val text: String val badge: RatingBadge + /** + * An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series + */ + val isEpisode: Boolean get() = false + data class RottenTomatoes( val score: RtScore, /** Critics (Tomatometer) or audience (Popcornmeter) */ @@ -91,6 +96,14 @@ sealed interface RatingEntry { override val text: String get() = formatScore(score) override val badge: RatingBadge get() = RatingBadge.TMDB } + + data class EpisodeTmdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val badge: RatingBadge get() = RatingBadge.TMDB + override val isEpisode: Boolean get() = true + } } private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) @@ -107,8 +120,10 @@ data class SourceRatings( val rtAudience: RtScore? = null, val imdb: Double? = null, val tmdb: Double? = null, + /** The TMDB user score of the episode itself, for episodes */ + val episodeTmdb: Double? = null, ) { - val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null + val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null /** * Rotten Tomatoes entries in PWA order: critics then audience @@ -128,8 +143,17 @@ data class SourceRatings( listOfNotNull( imdb?.let { RatingEntry.Imdb(it) }, tmdb?.let { RatingEntry.Tmdb(it) }, + episodeTmdb?.let { RatingEntry.EpisodeTmdb(it) }, ) + /** + * These ratings of a series with the TMDB score of one of its episodes. Rotten Tomatoes stays for context, but the + * series' own TMDB score is dropped so there is a single TMDB number, which is the episode's. + * + * @param episodeScore from [episodeScore], if null these ratings are unchanged + */ + fun withEpisode(episodeScore: Double?): SourceRatings = if (episodeScore == null) this else copy(tmdb = null, episodeTmdb = episodeScore) + /** * The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB). * @@ -144,6 +168,15 @@ data class SourceRatings( const val MAX_PERCENT = 100.0 const val MAX_SCORE = 10.0 + /** + * An episode's TMDB user score by the PWA's rules for a user score: only if observed, within 0-10, and with at least one vote + * (no votes or no vote count is unrated, never 0) + */ + fun episodeScore( + score: Number?, + votes: Long?, + ): Double? = if (votes == null) null else userScore(score, votes) + /** * Builds ratings using the PWA's rules: * - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt index 20942c78..08d31a9d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -49,7 +49,9 @@ data class RatingsTarget( * Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else: * - movies show their own ratings * - series show their own ratings - * - seasons and episodes show their series' ratings, as the PWA does for the title page an episode belongs to + * - seasons show their series' ratings, as the PWA does for the title page a season belongs to + * - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season + * Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it. * - everything else (collections, music, photos, programs, ...) has none * * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from @@ -77,6 +79,21 @@ class SeerrRatingsService .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) .build() + private data class SeasonKey( + val tmdbId: Int, + val season: Int, + ) + + /** + * The TMDB vote of each episode (by episode number) of a series' season + */ + private val seasonCache = + CacheBuilder + .newBuilder() + .maximumSize(CACHE_SIZE) + .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) + .build>() + /** * The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page * don't carry their series' provider ids, so this is how they are found. @@ -159,7 +176,18 @@ class SeerrRatingsService /** * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty */ - fun cachedFor(item: BaseItem): SourceRatings? = knownTarget(item)?.let { cached(it.type, it.tmdbId) } + fun cachedFor(item: BaseItem): SourceRatings? { + val target = knownTarget(item) ?: return null + val series = cached(target.type, target.tmdbId) ?: return null + if (item.type != BaseItemKind.EPISODE) return series + val season = item.data.parentIndexNumber + val episode = item.data.indexNumber + if (season == null || episode == null) return series + val votes = seasonCache.getIfPresent(SeasonKey(target.tmdbId, season)) ?: return null + return series.withEpisode(episodeScore(votes[episode])) + } + + private fun episodeScore(vote: TmdbVote?) = vote?.let { SourceRatings.episodeScore(it.average, it.count) } /** * Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established @@ -185,12 +213,64 @@ class SeerrRatingsService seerrService.active .distinctUntilChanged() .mapLatest { active -> - if (active) resolveTarget(item)?.let { get(it.type, it.tmdbId) } else null + if (active) resolveTarget(item)?.let { getFor(item, it) } else null }, ) } } + /** + * The ratings for the target of an item: for an episode, those of its series with its own TMDB score + */ + private suspend fun getFor( + item: BaseItem, + target: RatingsTarget, + ): SourceRatings? { + val series = get(target.type, target.tmdbId) + val season = item.data.parentIndexNumber + val episode = item.data.indexNumber + if (item.type != BaseItemKind.EPISODE || season == null || episode == null) return series + val votes = getSeason(target.tmdbId, season) + if (series == null && votes == null) return null + return (series ?: SourceRatings()).withEpisode(episodeScore(votes?.get(episode))) + } + + /** + * The TMDB votes of the episodes of a season, fetched once for all of them. Null if the request failed, which is not cached. + */ + private suspend fun getSeason( + tmdbId: Int, + season: Int, + ): Map? { + val key = SeasonKey(tmdbId, season) + seasonCache.getIfPresent(key)?.let { return it } + val votes = + try { + seerrService.api.tvApi + .tvTvIdSeasonSeasonNumberGet(tvId = tmdbId, seasonNumber = season) + .episodes + .orEmpty() + .mapNotNull { episode -> + episode.episodeNumber?.let { it to TmdbVote(episode.voteAverage?.toDouble(), episode.voteCount) } + }.toMap() + } catch (ex: CancellationException) { + throw ex + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + // TMDB has no such season, that is an answer rather than a failure + emptyMap() + } else { + Timber.w(ex, "Error fetching season %s of %s", season, tmdbId) + return null + } + } catch (ex: Exception) { + Timber.w(ex, "Error fetching season %s of %s", season, tmdbId) + return null + } + seasonCache.put(key, votes) + return votes + } + /** * @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request * @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt index e2552ace..19567883 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign @@ -100,16 +101,19 @@ private fun rememberBadges(textStyle: TextStyle): Map * The text for the ratings: each as a badge and its number, separated by dots * * @param leadingDot whether a dot is put in front of the first rating because something precedes it + * @param episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score */ fun ratingsText( entries: List, leadingDot: Boolean, + episodeLabel: String, ): AnnotatedString = buildAnnotatedString { entries.forEachIndexed { index, entry -> if (index > 0 || leadingDot) dot() appendInlineContent(entry.badge.name) append(" ") + if (entry.isEpisode) append("$episodeLabel ") append(entry.text) } } @@ -131,9 +135,10 @@ fun SourceRatingsRow( val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles val community = DisplayToggle.COMMUNITY_RATING in enabled val critic = DisplayToggle.CRITIC_RATING in enabled + val episodeLabel = stringResource(R.string.rating_episode_label) val text = - remember(ratings, community, critic, leadingDot) { - ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot) } + remember(ratings, community, critic, leadingDot, episodeLabel) { + ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) } } ?: return val badges = rememberBadges(textStyle) Text( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b8a3578..84ad72c7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,6 +164,7 @@ S%1$02dE%2$02d S%1$02dE%2$02d-E%3$02d E%1$d + Ep Season %1$d TV Shows Interface diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index d174ccec..dc493638 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1097,6 +1097,61 @@ components: example: 0 description: Availability of the season in 4K readOnly: true + SeasonDetails: + type: object + description: The answer of the season endpoint. Everything TMDB may not know is nullable. + properties: + id: + type: integer + airDate: + type: string + nullable: true + name: + type: string + nullable: true + overview: + type: string + nullable: true + posterPath: + type: string + nullable: true + seasonNumber: + type: integer + episodes: + type: array + items: + $ref: '#/components/schemas/SeasonEpisode' + SeasonEpisode: + type: object + description: An episode of the season endpoint. voteAverage is TMDB's user score as a decimal, 0 when nobody voted. + properties: + id: + type: integer + name: + type: string + nullable: true + overview: + type: string + nullable: true + airDate: + type: string + nullable: true + episodeNumber: + type: integer + seasonNumber: + type: integer + showId: + type: integer + stillPath: + type: string + nullable: true + voteAverage: + type: number + nullable: true + voteCount: + type: integer + format: int64 + nullable: true TvShowStatus: type: string enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] @@ -6651,7 +6706,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Season' + $ref: '#/components/schemas/SeasonDetails' /tv/{tvId}/recommendations: get: summary: Get recommended TV series diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt index e2471275..e5c12a56 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -157,4 +157,48 @@ class SourceRatingsTest { assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty()) assertFalse(movie.isEmpty) } + + @Test + fun episodeScoreFollowsThePwaRulesForAUserScore() { + assertEquals(7.9, SourceRatings.episodeScore(7.9, 120)!!, 0.0) + // unrated, never 0 + assertNull(SourceRatings.episodeScore(0.0, 0)) + assertNull(SourceRatings.episodeScore(8.0, 0)) + assertNull(SourceRatings.episodeScore(8.0, null)) + // not observed or out of range + assertNull(SourceRatings.episodeScore(null, 10)) + assertNull(SourceRatings.episodeScore(10.5, 10)) + assertNull(SourceRatings.episodeScore(-1.0, 10)) + assertEquals(10.0, SourceRatings.episodeScore(10.0, 1)!!, 0.0) + } + + @Test + fun episodeKeepsTheSeriesRottenTomatoesAndHasOneLabelledTmdbNumber() { + val series = + SourceRatings.of(rtCriticsScore = 63, rtCriticsLabel = "Fresh", rtAudienceScore = 71, rtAudienceLabel = "Upright", tmdbScore = 7.6, tmdbVotes = 3000) + // the series only has its TMDB score because it has no other source + val withTmdbOnly = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 3000) + assertEquals(7.6, withTmdbOnly.tmdb!!, 0.0) + + val episode = withTmdbOnly.withEpisode(8.2) + assertNull(episode.tmdb) + val entries = episode.visibleEntries(community = true, critic = true) + assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) + assertEquals(listOf("8.2"), entries.map { it.text }) + assertEquals(listOf(true), entries.map { it.isEpisode }) + + val full = series.withEpisode(8.2).visibleEntries(community = true, critic = true) + assertEquals(listOf("63%", "71%", "8.2"), full.map { it.text }) + assertEquals(listOf(false, false, true), full.map { it.isEpisode }) + // the display settings treat it as a community (IMDb/TMDB) rating + assertEquals(listOf("8.2"), series.withEpisode(8.2).visibleEntries(community = true, critic = false).map { it.text }) + assertEquals(listOf("63%", "71%"), series.withEpisode(8.2).visibleEntries(community = false, critic = true).map { it.text }) + } + + @Test + fun withoutAnEpisodeScoreTheSeriesRatingsAreUnchanged() { + val series = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 10) + assertEquals(series, series.withEpisode(null)) + assertFalse(SourceRatings().withEpisode(5.0).isEmpty) + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt index 72baa65c..adb5ca33 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt @@ -7,6 +7,9 @@ import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.TvApi import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails +import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.SeerrItemType @@ -265,4 +268,159 @@ class SeerrRatingsItemsTest { assertEquals(true, cancelled) assertNull(service.cached(SeerrItemType.MOVIE, 8)) } + + private fun season(vararg episodes: Triple) = + SeasonDetails( + seasonNumber = 5, + episodes = episodes.map { (number, average, votes) -> SeasonEpisode(episodeNumber = number, voteAverage = average?.toBigDecimal(), voteCount = votes) }, + ) + + private fun episode( + season: Int?, + episode: Int?, + ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.EPISODE, seriesId = seriesId, parentIndexNumber = season, indexNumber = episode)) + + private fun seriesKnown() { + jellyfinHasSeries() + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings + } + + @Test + fun theSeasonIsFetchedOnceForEveryEpisodeOfIt() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns + season(Triple(1, 8.24, 120L), Triple(2, 7.0, 80L), Triple(3, 9.5, 10L)) + + val first = service.forItem(episode(5, 1)).first()!! + val second = service.forItem(episode(5, 2)).first()!! + val third = service.forItem(episode(5, 3)).first()!! + + // each episode has its own score next to the series' Rotten Tomatoes + assertEquals(listOf(8.24, 7.0, 9.5), listOf(first, second, third).map { it.episodeTmdb }) + assertEquals(80, first.rtCritics?.percent) + coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } + coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } + // and another episode of the season is known without waiting + assertEquals(7.0, service.cachedFor(episode(5, 2))!!.episodeTmdb!!, 0.0) + } + + @Test + fun eachSeasonIsItsOwnRequest() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 1) } returns season(Triple(1, 6.0, 5L)) + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 2) } returns season(Triple(1, 9.0, 5L)) + + assertEquals(6.0, service.forItem(episode(1, 1)).first()!!.episodeTmdb!!, 0.0) + assertEquals(9.0, service.forItem(episode(2, 1)).first()!!.episodeTmdb!!, 0.0) + // the same episode number of another season is not mistaken for it + assertNull(service.cachedFor(episode(3, 1))) + } + + @Test + fun unratedEpisodesShowOnlyTheSeriesRatings() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns + season(Triple(1, 0.0, 0L), Triple(2, 8.0, null), Triple(3, null, null)) + + listOf(1, 2, 3, 4).forEach { number -> + val ratings = service.forItem(episode(5, number)).first()!! + assertNull("episode $number", ratings.episodeTmdb) + assertEquals("episode $number", 80, ratings.rtCritics?.percent) + } + } + + @Test + fun episodeWithoutSeasonOrEpisodeNumberShowsTheSeriesRatingsWithoutAskingForTheSeason() = + runTest { + seriesKnown() + + assertEquals(80, service.forItem(episode(null, 1)).first()!!.rtCritics?.percent) + assertEquals(80, service.forItem(episode(5, null)).first()!!.rtCritics?.percent) + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + } + + @Test + fun episodeTmdbReplacesTheSeriesTmdbWhenThatWasTheOnlySource() = + runTest { + jellyfinHasSeries() + coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns TvTvIdRatingsGet200Response() + coEvery { tvApi.tvTvIdGet(tvId = seriesTmdb) } returns + mockk { + every { voteAverage } returns 7.6.toBigDecimal() + every { voteCount } returns 900 + } + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns season(Triple(1, 8.8, 40L)) + + val ratings = service.forItem(episode(5, 1)).first()!! + + assertNull(ratings.tmdb) + assertEquals(8.8, ratings.episodeTmdb!!, 0.0) + } + + @Test + fun failedSeasonRequestKeepsTheSeriesRatingsAndIsRetried() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } throws IOException("down") andThen + season(Triple(1, 8.0, 3L)) + + val first = service.forItem(episode(5, 1)).first()!! + assertNull(first.episodeTmdb) + assertEquals(80, first.rtCritics?.percent) + // not cached as "no score": the screen has to wait again + assertNull(service.cachedFor(episode(5, 1))) + + assertEquals(8.0, service.forItem(episode(5, 1)).first()!!.episodeTmdb!!, 0.0) + } + + @Test + fun seasonTmdbDoesNotKnowIsAnAnswer() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } throws ClientException("none", 404) + + assertNull(service.forItem(episode(9, 1)).first()!!.episodeTmdb) + service.forItem(episode(9, 2)).first() + + coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } + } + + @Test + fun seriesWithoutATmdbIdShowsNothingExtra() = + runTest { + jellyfinHasSeries(tmdb = null) + + assertNull(service.forItem(episode(5, 1)).first()) + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + } + + @Test + fun seasonsStillShowOnlyTheirSeriesRatings() = + runTest { + seriesKnown() + val season = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.SEASON, seriesId = seriesId, indexNumber = 5, parentIndexNumber = 5)) + + assertNull(service.forItem(season).first()!!.episodeTmdb) + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + } + + @Test + fun scrollingPastEpisodesNeverFetchesASeason() = + runTest { + seriesKnown() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } returns season(Triple(1, 8.0, 3L)) + repeat(10) { number -> + val job = launch { service.forItem(episode(5, number + 1)).collect { } } + runCurrent() + advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50) + job.cancel() + } + runCurrent() + + coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } + coVerify(exactly = 0) { userLibraryApi.getItem(any()) } + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt index a51c73a0..4fb3dc5c 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt @@ -10,7 +10,8 @@ import com.github.damontecres.wholphin.api.seerr.TvApi import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb -import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails +import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.SourceRatings @@ -162,6 +163,47 @@ class SourceRatingsRowTest { assertJellyfinRatingNotShown() } + private fun episodeWithScores(): BaseItem { + seriesRatings() + coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns + SeasonDetails(episodes = listOf(SeasonEpisode(episodeNumber = 1, voteAverage = 8.1.toBigDecimal(), voteCount = 50))) + return BaseItem( + BaseItemDto( + id = UUID.randomUUID(), + type = BaseItemKind.EPISODE, + providerIds = mapOf("Tmdb" to "331212"), + seriesId = seriesId, + parentIndexNumber = 5, + indexNumber = 1, + communityRating = 7.8f, + criticRating = 59f, + ), + ) + } + + @Test + fun episodeShowsItsOwnLabelledTmdbScoreNextToTheSeriesRottenTomatoes() { + show(episodeWithScores()) + assertShown("81%") + assertShown("45%") + assertShown("Ep 8.1") + assertJellyfinRatingNotShown() + } + + @Test + fun episodeScoreFollowsTheCommunityRatingSetting() { + show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING)) + assertShown("81%") + assertNotShown("Ep 8.1") + } + + @Test + fun episodeScoreStaysWhenRottenTomatoesIsHidden() { + show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING)) + assertShown("Ep 8.1") + assertNotShown("81%") + } + @Test fun seasonShowsItsSeriesRatings() { seriesRatings() @@ -228,12 +270,12 @@ class SourceRatingsRowTest { imdbVotes = 10, ) val entries = ratings.visibleEntries(community = true, critic = true) - val text = ratingsText(entries, leadingDot = false) + val text = ratingsText(entries, leadingDot = false, episodeLabel = "Ep") assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%")) assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8")) // dots only between entries, or also in front when something precedes the ratings assertEquals(2, text.text.split("\u2022").size - 1) - assertEquals(3, ratingsText(entries, leadingDot = true).text.split("\u2022").size - 1) + assertEquals(3, ratingsText(entries, leadingDot = true, episodeLabel = "Ep").text.split("\u2022").size - 1) } } -- 2.49.1