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 175789e8..a872d3be 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -45,7 +45,6 @@ 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 @@ -59,7 +58,6 @@ 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 @@ -121,9 +119,6 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var imageUrlService: ImageUrlService - @Inject - lateinit var seerrRatingsService: SeerrRatingsService - @Inject lateinit var refreshRateService: RefreshRateService @@ -268,10 +263,7 @@ class MainActivity : AppCompatActivity() { LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } - CompositionLocalProvider( - LocalImageUrlService provides imageUrlService, - LocalSeerrRatingsService provides seerrRatingsService, - ) { + CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { 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 4b38d398..de3a8f5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -1,5 +1,6 @@ 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 @@ -31,6 +32,7 @@ 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 @@ -232,6 +234,26 @@ 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") + } + } + }, ), ) @@ -347,6 +369,12 @@ 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 581a4aa2..1085a8a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -1,6 +1,10 @@ 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 /** @@ -39,32 +43,16 @@ data class RtScore( ) /** - * 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 + * One displayable rating */ @Immutable 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 + * The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge */ - val isEpisode: Boolean get() = false + val inlineContentId: String data class RottenTomatoes( val score: RtScore, @@ -72,14 +60,14 @@ sealed interface RatingEntry { val critics: Boolean, ) : RatingEntry { override val text: String get() = "${score.percent}%" - override val badge: RatingBadge + override val inlineContentId: String get() = when (score.mark) { - 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 + 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 } } @@ -87,25 +75,25 @@ sealed interface RatingEntry { val score: Double, ) : RatingEntry { override val text: String get() = formatScore(score) - override val badge: RatingBadge get() = RatingBadge.IMDB + override val inlineContentId: String get() = INLINE_IMDB } data class Tmdb( val score: Double, ) : 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 + override val inlineContentId: String get() = INLINE_TMDB } } +const val INLINE_RT_FRESH = "fresh" +const val INLINE_RT_ROTTEN = "rotten" +const val INLINE_RT_UPRIGHT = "rt_upright" +const val INLINE_RT_SPILLED = "rt_spilled" +const val INLINE_RT_UNKNOWN = "rt_unknown" +const val INLINE_IMDB = "imdb" +const val INLINE_TMDB = "tmdb" + private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) /** @@ -120,10 +108,8 @@ 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 && episodeTmdb == null + val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null /** * Rotten Tomatoes entries in PWA order: critics then audience @@ -143,40 +129,12 @@ 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). - * - * 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 - /** - * 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 @@ -229,3 +187,38 @@ 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 dbaa219e..4ce6583f 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.HomeSlimItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields 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 = HomeSlimItemFields, + fields = SlimItemFields, 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 = HomeSlimItemFields, + fields = SlimItemFields, 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 08d31a9d..bc59f211 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -9,19 +9,12 @@ 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 @@ -35,24 +28,7 @@ data class TmdbVote( ) /** - * 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 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 + * Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses * * Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from * `voteAverage`/`voteCount` and is only requested when no other source has a score. @@ -65,7 +41,6 @@ class SeerrRatingsService @Inject constructor( private val seerrService: SeerrService, - private val api: ApiClient, ) { private data class Key( val type: SeerrItemType, @@ -79,196 +54,43 @@ 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. - */ - 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)) - /** - * 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) } - } - - /** - * 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. - */ - 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 + private fun target(item: BaseItem): Pair? { + val type = + when (item.type) { + BaseItemKind.MOVIE -> SeerrItemType.MOVIE + BaseItemKind.SERIES -> SeerrItemType.TV + else -> return null } - return tmdb.orElse(null)?.let { RatingsTarget(type, it) } + val tmdbId = + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() ?: return null + return type to tmdbId } /** - * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty + * The already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it + * doesn't show the Jellyfin values first */ - fun cachedFor(item: BaseItem): SourceRatings? { - 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) } + fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) } /** - * Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established - * asynchronously after login. + * Ratings for a Jellyfin movie or series using its TMDB id. 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. + * Emits null while Seerr is not active, the item isn't a movie/series with a TMDB id, or the lookup failed. + * Callers should keep showing the Jellyfin values for null. */ - fun forItem( - item: BaseItem?, - 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 { 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 + 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 } } /** @@ -406,6 +228,5 @@ 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 f774a0da..30521965 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -8,6 +8,8 @@ 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 @@ -231,12 +233,19 @@ val MediaSegmentType.skipStringRes: Int fun AnnotatedString.Builder.dot() = append(" \u2022 ") -fun listToDotString(strings: List): AnnotatedString = +fun listToDotString( + strings: List, + ratings: SourceRatings?, +): 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 786db3d5..2e57b407 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,22 +75,12 @@ 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 967ddb07..a048ed94 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, null) + QuickDetails(item.ui.quickDetails, 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 902ce814..d4228be0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -1,24 +1,41 @@ 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.BaseItem +import com.github.damontecres.wholphin.data.model.INLINE_IMDB +import com.github.damontecres.wholphin.data.model.INLINE_RT_FRESH +import com.github.damontecres.wholphin.data.model.INLINE_RT_ROTTEN +import com.github.damontecres.wholphin.data.model.INLINE_RT_SPILLED +import com.github.damontecres.wholphin.data.model.INLINE_RT_UNKNOWN +import com.github.damontecres.wholphin.data.model.INLINE_RT_UPRIGHT +import com.github.damontecres.wholphin.data.model.INLINE_TMDB import com.github.damontecres.wholphin.data.model.QuickDetailsData -import com.github.damontecres.wholphin.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 @@ -26,12 +43,6 @@ 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?, @@ -39,16 +50,24 @@ 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) + QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap) if (DisplayToggle.OFFICIAL_RATING in enabled) { - QuickDetailsText(details.officialRating, Modifier, textStyle) + 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) } - SourceRatingsRow(ratings, Modifier, textStyle) } if (timeRemaining != null) { TimeRemaining(timeRemaining, textStyle = textStyle) @@ -58,41 +77,64 @@ 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 460eda05..c52fb52f 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,29 +1,93 @@ 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) -/** - * 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. - */ +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, + ) +} + @Composable fun SimpleStarRating( text: String?, @@ -51,3 +115,206 @@ 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 deleted file mode 100644 index 19567883..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt +++ /dev/null @@ -1,152 +0,0 @@ -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.res.stringResource -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 - * @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) - } - } - -/** - * 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 episodeLabel = stringResource(R.string.rating_episode_label) - val text = - remember(ratings, community, critic, leadingDot, episodeLabel) { - ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) } - } ?: 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 34bf4657..51a537e3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -20,7 +20,6 @@ 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 @@ -28,7 +27,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.QuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -80,7 +79,7 @@ fun DiscoverMovieDetailsHeader( ) { val resources = LocalResources.current val details = - remember(movie, resources) { + remember(movie, rating, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime @@ -106,11 +105,14 @@ fun DiscoverMovieDetailsHeader( ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { - QuickDetailsData(listToDotString(it)) + listToDotString( + it, + rating, + ) } } - QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) 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 ef96bb7e..88fa0b50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -34,7 +34,6 @@ 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 @@ -56,7 +55,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.QuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -454,7 +453,7 @@ fun DiscoverSeriesDetailsHeader( ) { val resources = LocalResources.current val details = - remember(series, resources) { + remember(series, rating, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -465,11 +464,14 @@ fun DiscoverSeriesDetailsHeader( ?.let { add(resources.formatDuration(it)) } // TODO }.let { - QuickDetailsData(listToDotString(it)) + listToDotString( + it, + rating, + ) } } - QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) 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 3c341ff2..e8872180 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, ep.timeRemainingOrRuntime) + QuickDetails(ep.ui.quickDetails, 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 ef0d186e..bf8d698f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -339,6 +339,7 @@ fun MovieDetailsContent( chosenStreams = state.chosenStreams, bringIntoViewRequester = bringIntoViewRequester, overviewOnClick = overviewOnClick, + sourceRatings = state.sourceRatings, modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index 6dbbf965..13ee747c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -20,6 +20,8 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.data.model.withSourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.HeaderUtils @@ -41,8 +43,10 @@ fun MovieDetailsHeader( bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, modifier: Modifier = Modifier, + sourceRatings: SourceRatings? = null, ) { val dto = movie.data + val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) } val context = LocalContext.current val scope = rememberCoroutineScope() Column( @@ -64,7 +68,7 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - movie, + quickDetails, movie.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index db6a6f6a..fc9e7ad6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.Chapter import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.ExtrasService @@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService @@ -57,6 +59,7 @@ class MovieViewModel constructor( private val api: ApiClient, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -100,6 +103,8 @@ class MovieViewModel return item } + private var ratingsJob: Job? = null + fun init(): Job = viewModelScope.launchDefault { val movie = @@ -124,6 +129,7 @@ class MovieViewModel chosenStreams = chosenStreams, trailers = remoteTrailers, chapters = chapters, + sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie), ) } backdropService.submit(movie) @@ -152,6 +158,14 @@ class MovieViewModel ) } } + // init() runs again on resume and the flow never completes, so don't stack collectors + ratingsJob?.cancel() + ratingsJob = + viewModelScope.launchIO { + ratingsService.forItem(movie).collect { ratings -> + _state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) } + } + } viewModelScope.launchIO { val results = seerrService.similar(movie).orEmpty() _state.update { @@ -306,6 +320,7 @@ 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 be7795f4..b63f1cbc 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?.let { + ep?.ui?.quickDetails?.let { QuickDetails( it, - it.timeRemainingOrRuntime, + ep.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 0308468e..60419e83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -44,8 +44,10 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.studioNames +import com.github.damontecres.wholphin.data.model.withSourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards @@ -253,6 +255,7 @@ fun SeriesDetails( } }, discovered = state.discovered, + sourceRatings = state.sourceRatings, onClickDiscover = { index, item -> viewModel.navigateTo(item.destination) }, @@ -330,6 +333,7 @@ fun SeriesDetailsContent( extras: List, people: List, discovered: List, + sourceRatings: SourceRatings?, played: Boolean, favorite: Boolean, canDelete: Boolean, @@ -377,6 +381,7 @@ fun SeriesDetailsContent( showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, bringIntoViewRequester = bringIntoViewRequester, + sourceRatings = sourceRatings, modifier = Modifier .fillMaxWidth() @@ -654,9 +659,11 @@ fun SeriesDetailsHeader( overviewOnClick: () -> Unit, bringIntoViewRequester: BringIntoViewRequester, modifier: Modifier = Modifier, + sourceRatings: SourceRatings? = null, ) { val scope = rememberCoroutineScope() val dto = series.data + val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) } Column( verticalArrangement = Arrangement.spacedBy(4.dp), modifier = modifier, @@ -674,7 +681,7 @@ fun SeriesDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - series, + quickDetails, null, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 32f1d484..caaa1fb5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.preferences.AppPreferences import com.github.damontecres.wholphin.services.BackdropService @@ -20,6 +21,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.MediaManagementService import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.ServerReportService import com.github.damontecres.wholphin.services.StreamChoiceService @@ -100,6 +102,7 @@ class SeriesViewModel private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val mediaManagementService: MediaManagementService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @@ -218,6 +221,7 @@ class SeriesViewModel seasons = seasons, episodes = episodes, trailers = remoteTrailers, + sourceRatings = ratingsService.cachedFor(series), ) } @@ -251,6 +255,11 @@ class SeriesViewModel _state.update { it.copy(similar = similar) } } } + viewModelScope.launchIO { + ratingsService.forItem(series).collect { ratings -> + _state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) } + } + } viewModelScope.launchIO { val results = seerrService.similar(series).orEmpty() _state.update { it.copy(discovered = results) } @@ -896,5 +905,6 @@ 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 62075882..4bdc42ba 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -389,7 +389,7 @@ fun SeerrDiscoverPage( modifier = modifier, ) { val details = - remember(focusedItem) { + remember(focusedItem, ratingMap) { (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> buildList { focusedItem.item @@ -398,18 +398,17 @@ fun SeerrDiscoverPage( ?.toString() ?.let(::add) }.let { - QuickDetailsData(listToDotString(it)) + val rating = ratingMap[focusedItem.item.type to focusedItem.id] + QuickDetailsData(listToDotString(it, rating)) } } } - 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 26cc96d5..bf24ae00 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,7 +52,6 @@ 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 @@ -73,7 +72,6 @@ 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 @@ -573,7 +571,6 @@ fun HomePageHeader( overview = item?.data?.overview, overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails, - ratings = rememberSourceRatings(item), timeRemaining = item?.timeRemainingOrRuntime, endsAt = item?.data?.endDate, showLogo = showLogo, @@ -589,7 +586,6 @@ fun HomePageHeader( overview: String?, overviewTwoLines: Boolean, quickDetails: QuickDetailsData?, - ratings: SourceRatings?, timeRemaining: Duration?, endsAt: DateTime?, showLogo: Boolean, @@ -615,7 +611,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) val overviewModifier = Modifier .padding(0.dp) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84ad72c7..7b8a3578 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -164,7 +164,6 @@ 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 dc493638..d174ccec 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1097,61 +1097,6 @@ 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"] @@ -6706,7 +6651,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SeasonDetails' + $ref: '#/components/schemas/Season' /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 e5c12a56..41fee583 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -1,8 +1,10 @@ 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 @@ -102,103 +104,44 @@ class SourceRatingsTest { imdbVotes = 1, ) assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text }) - assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge }) + assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId }) assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) - assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge }) + assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId }) + } + + private val jellyfinDetails = + QuickDetailsData( + basic = AnnotatedString("2020"), + communityRating = AnnotatedString("7.8 jellyfin"), + criticRating = AnnotatedString("59% jellyfin"), + ) + + @Test + fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() { + assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null)) + assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings())) } @Test - fun 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, + fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() { + val details = + jellyfinDetails.withSourceRatings( + SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10), ) - 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 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()) + // COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes + assertTrue(details.communityRating!!.text.endsWith("8.8")) + assertTrue(details.criticRating!!.text.endsWith("96%")) + assertEquals(jellyfinDetails.basic, details.basic) + // PWA order: Rotten Tomatoes before IMDb + assertTrue(details.criticRatingFirst) + assertFalse(jellyfinDetails.criticRatingFirst) } @Test - fun 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 noEntriesWithoutRatings() { - assertTrue(SourceRatings().isEmpty) - 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) + fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() { + // Seerr has only RT: the Jellyfin star is not shown next to it + val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh")) + assertNull(details.communityRating) + assertTrue(details.criticRating!!.text.endsWith("50%")) } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt deleted file mode 100644 index adb5ca33..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt +++ /dev/null @@ -1,426 +0,0 @@ -@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.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 -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)) - } - - 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/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt index c6b008ea..2be93089 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt @@ -21,7 +21,6 @@ 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 @@ -46,7 +45,7 @@ class SeerrRatingsServiceTest { every { api } returns apiClient every { active } answers { activeFlow } } - private val service = SeerrRatingsService(seerrService, mockk()) + private val service = SeerrRatingsService(seerrService) private fun movieDetails( average: Double?, @@ -254,7 +253,8 @@ 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 something that has ratings + // not a movie/series + assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first()) assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first()) // call fails assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first()) 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 deleted file mode 100644 index 4fb3dc5c..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt +++ /dev/null @@ -1,281 +0,0 @@ -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.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 -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() - } - - 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() - 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, 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, episodeLabel = "Ep").text.split("\u2022").size - 1) - } -}