Show PWA source ratings on every surface through one shared rating row #26

Merged
justinzeus merged 2 commits from fm/wolfskin-25-ratings-everywhere into main 2026-10-29 21:51:24 +01:00
28 changed files with 1361 additions and 567 deletions

View file

@ -45,6 +45,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
import com.github.damontecres.wholphin.services.RefreshRateService
import com.github.damontecres.wholphin.services.ScreensaverService
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.ServerEventListener
import com.github.damontecres.wholphin.services.SetupDestination
import com.github.damontecres.wholphin.services.SetupNavigationManager
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.CoilConfig
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.collectLatestIn
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
@ -119,6 +121,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var imageUrlService: ImageUrlService
@Inject
lateinit var seerrRatingsService: SeerrRatingsService
@Inject
lateinit var refreshRateService: RefreshRateService
@ -263,7 +268,10 @@ class MainActivity : AppCompatActivity() {
LaunchedEffect(appPreferences.debugLogging) {
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
}
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
CompositionLocalProvider(
LocalImageUrlService provides imageUrlService,
LocalSeerrRatingsService provides seerrRatingsService,
) {
WholphinTheme(
true,
appThemeColors = appPreferences.interfacePreferences.appThemeColors,

View file

@ -1,6 +1,5 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.ui.text.AnnotatedString
@ -32,7 +31,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.CollectionType
import org.jellyfin.sdk.model.extensions.ticks
import java.util.Locale
import java.util.UUID
import kotlin.time.Duration
@ -234,26 +232,6 @@ data class BaseItem(
append(it)
}
},
communityRating =
data.communityRating?.let {
buildAnnotatedString {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
},
criticRating =
data.criticRating?.let {
buildAnnotatedString {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
}
},
),
)
@ -369,12 +347,6 @@ data class BaseItemUi(
data class QuickDetailsData(
val basic: AnnotatedString,
val officialRating: AnnotatedString? = null,
val criticRating: AnnotatedString? = null,
val communityRating: AnnotatedString? = null,
/**
* Show [criticRating] (Rotten Tomatoes) before [communityRating] (IMDb/TMDB) like the PWA, instead of the Jellyfin order
*/
val criticRatingFirst: Boolean = false,
)
/**

View file

@ -1,10 +1,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.ui.dot
import java.util.Locale
/**
@ -43,16 +39,32 @@ data class RtScore(
)
/**
* One displayable rating
* The badge drawn in front of a rating's number. One per PWA icon.
*/
enum class RatingBadge {
RT_FRESH,
RT_ROTTEN,
RT_UPRIGHT,
RT_SPILLED,
/** Rotten Tomatoes without a recognised label, drawn muted */
RT_UNKNOWN,
IMDB,
TMDB,
}
/**
* One displayable rating: a badge and its number
*/
@Immutable
sealed interface RatingEntry {
val text: String
val badge: RatingBadge
/**
* The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge
* An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series
*/
val inlineContentId: String
val isEpisode: Boolean get() = false
data class RottenTomatoes(
val score: RtScore,
@ -60,14 +72,14 @@ sealed interface RatingEntry {
val critics: Boolean,
) : RatingEntry {
override val text: String get() = "${score.percent}%"
override val inlineContentId: String
override val badge: RatingBadge
get() =
when (score.mark) {
RtMark.FRESH -> INLINE_RT_FRESH
RtMark.ROTTEN -> INLINE_RT_ROTTEN
RtMark.UPRIGHT -> INLINE_RT_UPRIGHT
RtMark.SPILLED -> INLINE_RT_SPILLED
RtMark.UNKNOWN -> INLINE_RT_UNKNOWN
RtMark.FRESH -> RatingBadge.RT_FRESH
RtMark.ROTTEN -> RatingBadge.RT_ROTTEN
RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT
RtMark.SPILLED -> RatingBadge.RT_SPILLED
RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN
}
}
@ -75,25 +87,25 @@ sealed interface RatingEntry {
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val inlineContentId: String get() = INLINE_IMDB
override val badge: RatingBadge get() = RatingBadge.IMDB
}
data class Tmdb(
val score: Double,
) : RatingEntry {
override val text: String get() = formatScore(score)
override val inlineContentId: String get() = INLINE_TMDB
override val badge: RatingBadge get() = RatingBadge.TMDB
}
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
}
}
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)
/**
@ -108,8 +120,10 @@ data class SourceRatings(
val rtAudience: RtScore? = null,
val imdb: Double? = null,
val tmdb: Double? = null,
/** The TMDB user score of the episode itself, for episodes */
val episodeTmdb: Double? = null,
) {
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null
/**
* Rotten Tomatoes entries in PWA order: critics then audience
@ -129,12 +143,40 @@ 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<RatingEntry> = (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
@ -187,38 +229,3 @@ data class SourceRatings(
}
}
}
/**
* Appends each entry as a dot, its badge, and its score
*/
fun AnnotatedString.Builder.appendRatings(entries: List<RatingEntry>) {
entries.forEach {
dot()
appendInlineContent(it.inlineContentId)
append(" ")
append(it.text)
}
}
/**
* Replaces the Jellyfin community & critic ratings with these ratings.
*
* IMDb/TMDB are shown with the community rating setting, Rotten Tomatoes with the critic rating setting.
* If there are no ratings, the Jellyfin values are kept.
*/
fun QuickDetailsData.withSourceRatings(ratings: SourceRatings?): QuickDetailsData =
if (ratings == null || ratings.isEmpty) {
this
} else {
copy(
communityRating =
ratings.communityEntries.takeIf { it.isNotEmpty() }?.let {
buildAnnotatedString { appendRatings(it) }
},
criticRating =
ratings.criticEntries.takeIf { it.isNotEmpty() }?.let {
buildAnnotatedString { appendRatings(it) }
},
criticRatingFirst = true,
)
}

View file

@ -6,7 +6,7 @@
package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.HomeSlimItemFields
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
import com.github.damontecres.wholphin.util.WholphinDispatchers
import kotlinx.coroutines.async
@ -58,7 +58,7 @@ class LatestNextUpService
val request =
GetResumeItemsRequest(
userId = userId,
fields = SlimItemFields,
fields = HomeSlimItemFields,
limit = limit,
mediaTypes = listOf(MediaType.VIDEO),
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
@ -90,7 +90,7 @@ class LatestNextUpService
val request =
GetNextUpRequest(
userId = userId,
fields = SlimItemFields,
fields = HomeSlimItemFields,
imageTypeLimit = 1,
parentId = null,
limit = limit,

View file

@ -9,12 +9,19 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
import com.google.common.cache.CacheBuilder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
import java.util.Optional
import java.util.UUID
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@ -28,7 +35,24 @@ data class TmdbVote(
)
/**
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses
* The Seerr title whose ratings are shown for a Jellyfin item
*/
data class RatingsTarget(
val type: SeerrItemType,
val tmdbId: Int,
)
/**
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings
* reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings].
*
* Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else:
* - movies show their own ratings
* - series show their own ratings
* - seasons show their series' ratings, as the PWA does for the title page a season belongs to
* - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season
* Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it.
* - everything else (collections, music, photos, programs, ...) has none
*
* Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from
* `voteAverage`/`voteCount` and is only requested when no other source has a score.
@ -41,6 +65,7 @@ class SeerrRatingsService
@Inject
constructor(
private val seerrService: SeerrService,
private val api: ApiClient,
) {
private data class Key(
val type: SeerrItemType,
@ -54,43 +79,196 @@ class SeerrRatingsService
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
.build<Key, SourceRatings>()
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<SeasonKey, Map<Int, TmdbVote>>()
/**
* 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<UUID, Optional<Int>>()
fun cached(
type: SeerrItemType,
tmdbId: Int,
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
private fun target(item: BaseItem): Pair<SeerrItemType, Int>? {
val type =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES -> SeerrItemType.TV
else -> return null
}
val tmdbId =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull() ?: return null
return type to tmdbId
/**
* The 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<Int>? =
item.data.providerIds
?.takeIf { ownerId == item.id && it.isNotEmpty() }
?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) }
?: tmdbIds.getIfPresent(ownerId)
private fun type(item: BaseItem): SeerrItemType? =
when (item.type) {
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV
else -> null
}
private fun knownTarget(item: BaseItem): RatingsTarget? {
val type = type(item) ?: return null
val owner = ownerId(item) ?: return null
return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) }
}
/**
* The already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it
* doesn't show the Jellyfin values first
* Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand.
* Null if the item has no ratings, no TMDB id or the lookup failed.
*/
fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) }
suspend fun resolveTarget(item: BaseItem): RatingsTarget? {
val type = type(item) ?: return null
val owner = ownerId(item) ?: return null
val tmdb =
knownTmdbId(item, owner) ?: try {
Optional
.ofNullable(
api.userLibraryApi
.getItem(owner)
.content.providerIds
?.get("Tmdb")
?.toIntOrNull(),
).also { tmdbIds.put(owner, it) }
} catch (ex: CancellationException) {
throw ex
} catch (ex: Exception) {
Timber.w(ex, "Error fetching provider ids for %s", owner)
return null
}
return tmdb.orElse(null)?.let { RatingsTarget(type, it) }
}
/**
* Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the
* connection is established asynchronously after login.
*
* Emits null while Seerr is not active, the item isn't a movie/series with a TMDB id, or the lookup failed.
* Callers should keep showing the Jellyfin values for null.
* The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty
*/
fun forItem(item: BaseItem): Flow<SourceRatings?> {
val (type, tmdbId) = target(item) ?: return flowOf(null)
return seerrService.active
.distinctUntilChanged()
.mapLatest { active -> if (active) get(type, tmdbId) else null }
fun cachedFor(item: BaseItem): SourceRatings? {
val target = knownTarget(item) ?: return null
val series = cached(target.type, target.tmdbId) ?: return null
if (item.type != BaseItemKind.EPISODE) return series
val season = item.data.parentIndexNumber
val episode = item.data.indexNumber
if (season == null || episode == null) return series
val votes = seasonCache.getIfPresent(SeasonKey(target.tmdbId, season)) ?: return null
return series.withEpisode(episodeScore(votes[episode]))
}
private fun episodeScore(vote: TmdbVote?) = vote?.let { SourceRatings.episodeScore(it.average, it.count) }
/**
* Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established
* asynchronously after login.
*
* Already cached ratings are emitted at once. Otherwise nothing is requested until [debounceMillis] has passed, so
* collecting for each focused item while scrolling quickly only asks Seerr about the item the user stops on:
* collecting is cancelled for the previous item, which cancels the wait or the request in progress.
*
* Emits null while Seerr is not active, the item has no ratings (see above) or no TMDB id, or the lookup failed.
*/
fun forItem(
item: BaseItem?,
debounceMillis: Long = DEBOUNCE_MILLIS,
): Flow<SourceRatings?> {
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<Int, TmdbVote>? {
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
}
/**
@ -228,5 +406,6 @@ class SeerrRatingsService
companion object {
private const val CACHE_SIZE = 300L
private const val CACHE_HOURS = 6L
const val DEBOUNCE_MILLIS = 350L
}
}

View file

@ -8,8 +8,6 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.appendRatings
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
@ -233,19 +231,12 @@ val MediaSegmentType.skipStringRes: Int
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
fun listToDotString(
strings: List<String>,
ratings: SourceRatings?,
): AnnotatedString =
fun listToDotString(strings: List<String>): AnnotatedString =
buildAnnotatedString {
strings.forEachIndexed { index, string ->
append(string)
if (index != strings.lastIndex) dot()
}
ratings?.let {
appendRatings(it.criticEntries)
appendRatings(it.communityEntries)
}
}
@get:StringRes

View file

@ -75,12 +75,22 @@ val SlimItemFields =
*/
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
/**
* [ItemFields] for the home page's rows. [ItemFields.PROVIDER_IDS] is needed so a focused movie or series has its TMDB id
* for [com.github.damontecres.wholphin.services.SeerrRatingsService] without another request.
*/
val HomeItemFields =
listOf(
ItemFields.OVERVIEW,
ItemFields.CAN_DELETE,
ItemFields.PROVIDER_IDS,
)
/**
* [SlimItemFields] for the home page's continue watching & next up rows, see [HomeItemFields]
*/
val HomeSlimItemFields = SlimItemFields + ItemFields.PROVIDER_IDS
val PhotoItemFields =
DetailItemFields +
listOf(

View file

@ -250,7 +250,7 @@ fun CollectionFolderListDetails(
)
item?.let {
QuickDetails(item.ui.quickDetails, null)
QuickDetails(item, null)
}
OverviewText(

View file

@ -1,41 +1,24 @@
package com.github.damontecres.wholphin.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.INLINE_IMDB
import com.github.damontecres.wholphin.data.model.INLINE_RT_FRESH
import com.github.damontecres.wholphin.data.model.INLINE_RT_ROTTEN
import com.github.damontecres.wholphin.data.model.INLINE_RT_SPILLED
import com.github.damontecres.wholphin.data.model.INLINE_RT_UNKNOWN
import com.github.damontecres.wholphin.data.model.INLINE_RT_UPRIGHT
import com.github.damontecres.wholphin.data.model.INLINE_TMDB
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.DisplayToggle
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.ui.dot
import com.github.damontecres.wholphin.ui.formatTime
import com.github.damontecres.wholphin.ui.util.LocalClock
@ -43,6 +26,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
import org.jellyfin.sdk.model.DateTime
import kotlin.time.Duration
/**
* The line of details under a title: year, runtime, etc, then the ratings, then when it ends.
*
* Ratings are only drawn for [ratings], never from the Jellyfin item. Pass the result of [rememberSourceRatings] or null
* for surfaces without ratings.
*/
@Composable
fun QuickDetails(
details: QuickDetailsData?,
@ -50,24 +39,16 @@ fun QuickDetails(
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
ratings: SourceRatings? = null,
) {
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
Row(modifier = modifier) {
if (details != null) {
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap)
QuickDetailsText(details.basic, Modifier, textStyle)
if (DisplayToggle.OFFICIAL_RATING in enabled) {
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap)
}
val community = DisplayToggle.COMMUNITY_RATING in enabled
val critic = DisplayToggle.CRITIC_RATING in enabled
if (details.criticRatingFirst) {
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
} else {
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
QuickDetailsText(details.officialRating, Modifier, textStyle)
}
SourceRatingsRow(ratings, Modifier, textStyle)
}
if (timeRemaining != null) {
TimeRemaining(timeRemaining, textStyle = textStyle)
@ -77,64 +58,41 @@ fun QuickDetails(
}
}
/**
* [QuickDetails] for a Jellyfin item with the ratings the PWA shows for it, if any (see [SeerrRatingsService]).
*/
@Composable
fun QuickDetails(
item: BaseItem,
timeRemaining: Duration?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
endsAt: DateTime? = null,
) = QuickDetails(
details = item.ui.quickDetails,
timeRemaining = timeRemaining,
modifier = modifier,
textStyle = textStyle,
endsAt = endsAt,
ratings = rememberSourceRatings(item),
)
@NonRestartableComposable
@Composable
fun QuickDetailsText(
str: AnnotatedString?,
modifier: Modifier = Modifier,
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
inlineContentMap: Map<String, InlineTextContent> = 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,

View file

@ -1,93 +1,29 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.LocalContentColor
import androidx.tv.material3.LocalTextStyle
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.ui.AppColors
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.playOnClickSound
import com.github.damontecres.wholphin.ui.playSoundOnFocus
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import com.github.damontecres.wholphin.ui.tryRequestFocus
import java.util.Locale
enum class StarRatingPrecision {
FULL,
HALF,
QUARTER,
;
companion object {
fun fromFloat(value: Float): StarRatingPrecision =
if (value <= .25f) {
QUARTER
} else if (value <= .5f) {
HALF
} else {
FULL
}
}
}
val FilledStarColor = Color(0xFFFFC700)
val EmptyStarColor = Color(0xFF5D4A0A)
val ratingBarHeight: Dp = 32.dp
@Composable
fun SimpleStarRating(
communityRating: Float?,
modifier: Modifier = Modifier,
) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
SimpleStarRating(
text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) },
modifier = modifier,
)
}
/**
* A minimum rating such as "7+" with a star, for the rating filter menus.
*
* This is not for showing a title's rating, see [SourceRatingsRow] for that.
*/
@Composable
fun SimpleStarRating(
text: String?,
@ -115,206 +51,3 @@ fun SimpleStarRating(
}
}
}
@Composable
fun TomatoRating(
rating: Float?,
modifier: Modifier = Modifier,
threshold: Float = 60f,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = modifier,
) {
if (rating != null) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Text(
text = rating.toInt().toString() + "%",
maxLines = 1,
modifier = Modifier,
)
}
val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() * .8f }
Icon(
painter =
painterResource(
if (rating >= threshold) {
R.drawable.ic_rotten_tomatoes_fresh
} else {
R.drawable.ic_rotten_tomatoes_rotten
},
),
contentDescription = null,
modifier = Modifier.height(height),
tint = Color.Unspecified,
)
}
}
}
/**
* Shows a rating out of 5 stars
*/
@Composable
fun StarRating(
rating100: Int,
onRatingChange: (Int) -> Unit,
precision: StarRatingPrecision,
enabled: Boolean,
playSoundOnFocus: Boolean,
modifier: Modifier = Modifier,
bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background,
) {
val context = LocalContext.current
var tempRating by remember(rating100) { mutableIntStateOf(rating100) }
val percentage = (if (enabled) tempRating else rating100) / 100f
val focusRequesters = remember { List(5) { FocusRequester() } }
Box(
modifier =
modifier
.clip(RoundedCornerShape(16.dp))
.background(bgColor),
) {
LazyRow(
modifier =
Modifier
.selectableGroup()
.padding(4.dp)
.drawWithCache {
onDrawWithContent {
drawContent()
if (percentage in 0f..<1f) {
drawRect(
color = bgColor,
topLeft = Offset(size.width * percentage, 0f),
blendMode = BlendMode.SrcAtop,
)
}
}
}.focusGroup()
.focusProperties {
onEnter = {
val index =
if (rating100 <= 20) {
0
} else if (rating100 <= 40) {
1
} else if (rating100 <= 60) {
2
} else if (rating100 <= 80) {
3
} else {
4
}
focusRequesters[index].tryRequestFocus()
}
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
for (i in 1..5) {
item {
val isRated = (if (enabled) tempRating else rating100) >= (i * 20)
val icon = Icons.Filled.Star
var focused by remember { mutableStateOf(false) }
val focusedColor =
if (focused) {
MaterialTheme.colorScheme.border
} else {
Color.Unspecified
}
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(focusedColor),
) {
Icon(
imageVector = icon,
tint = FilledStarColor,
contentDescription = null,
modifier =
if (enabled) {
Modifier
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) {
tempRating = i * 20
} else {
tempRating = rating100
}
}.playSoundOnFocus(playSoundOnFocus)
.focusRequester(focusRequesters[i - 1])
.focusProperties {
left =
if (i == 1) focusRequesters.last() else FocusRequester.Default
right =
if (i == 5) focusRequesters.first() else FocusRequester.Default
}.selectable(
selected = isRated,
onClick = {
if (playSoundOnFocus) playOnClickSound(context)
val newRating100 =
when (precision) {
StarRatingPrecision.FULL -> {
if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20
}
StarRatingPrecision.HALF -> {
if (rating100 > i * 20) {
i * 20
} else if (rating100 == i * 20) {
i * 20 - 10
} else if (rating100 == i * 20 - 10) {
(i - 1) * 20
} else if (i == 1 && rating100 == 0) {
20
} else if (i == 1 && rating100 > 10) {
10
} else if (i == 1 && rating100 <= 10) {
0
} else {
(i) * 20
}
}
StarRatingPrecision.QUARTER -> {
// TODO
null
}
}
if (newRating100 != null) {
tempRating = newRating100
onRatingChange(newRating100)
}
},
)
} else {
Modifier
}.fillMaxHeight()
.aspectRatio(1f),
)
}
}
}
}
}
}
@PreviewTvSpec
@Composable
private fun SimpleStarRatingPreview() {
WholphinTheme {
Column {
SimpleStarRating(7.5f, Modifier.height(32.dp))
TomatoRating(75f)
SimpleStarRating(7.5f, Modifier.height(20.dp))
SimpleStarRating("", Modifier.height(32.dp))
TomatoRating(75f)
TomatoRating(40f)
TomatoRating(3f)
}
}
}

View file

@ -0,0 +1,152 @@
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<SeerrRatingsService> { 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<String, InlineTextContent> =
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<RatingEntry>,
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,
)
}

View file

@ -20,6 +20,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.jellyfinId
@ -27,7 +28,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.formatDuration
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader(
) {
val resources = LocalResources.current
val details =
remember(movie, rating, resources) {
remember(movie, resources) {
buildList {
movie.releaseDate?.let(::add)
movie.runtime
@ -105,14 +106,11 @@ fun DiscoverMovieDetailsHeader(
?.takeIf { it.isNotNullOrBlank() }
?.let(::add)
}.let {
listToDotString(
it,
rating,
)
QuickDetailsData(listToDotString(it))
}
}
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
}

View file

@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
@ -55,7 +56,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.OverviewText
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -453,7 +454,7 @@ fun DiscoverSeriesDetailsHeader(
) {
val resources = LocalResources.current
val details =
remember(series, rating, resources) {
remember(series, resources) {
buildList {
series.firstAirDate?.let(::add)
series.episodeRunTime
@ -464,14 +465,11 @@ fun DiscoverSeriesDetailsHeader(
?.let { add(resources.formatDuration(it)) }
// TODO
}.let {
listToDotString(
it,
rating,
)
QuickDetailsData(listToDotString(it))
}
}
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
series.genres?.mapNotNull { it.name }?.letNotEmpty {
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
}

View file

@ -55,7 +55,7 @@ fun EpisodeDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
val padding = 8.dp
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
QuickDetails(ep, ep.timeRemainingOrRuntime)
VideoStreamDetails(
chosenStreams = chosenStreams,

View file

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

View file

@ -20,8 +20,6 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ChosenStreams
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.withSourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.GenreText
import com.github.damontecres.wholphin.ui.components.HeaderUtils
@ -43,10 +41,8 @@ fun MovieDetailsHeader(
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
modifier: Modifier = Modifier,
sourceRatings: SourceRatings? = null,
) {
val dto = movie.data
val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
Column(
@ -68,7 +64,7 @@ fun MovieDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
quickDetails,
movie,
movie.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding),
)

View file

@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ExtrasService
@ -20,7 +19,6 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService
@ -59,7 +57,6 @@ class MovieViewModel
constructor(
private val api: ApiClient,
private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
@param:ApplicationContext private val context: Context,
private val navigationManager: NavigationManager,
val serverRepository: ServerRepository,
@ -103,8 +100,6 @@ class MovieViewModel
return item
}
private var ratingsJob: Job? = null
fun init(): Job =
viewModelScope.launchDefault {
val movie =
@ -129,7 +124,6 @@ class MovieViewModel
chosenStreams = chosenStreams,
trailers = remoteTrailers,
chapters = chapters,
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
)
}
backdropService.submit(movie)
@ -158,14 +152,6 @@ class MovieViewModel
)
}
}
// init() runs again on resume and the flow never completes, so don't stack collectors
ratingsJob?.cancel()
ratingsJob =
viewModelScope.launchIO {
ratingsService.forItem(movie).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO {
val results = seerrService.similar(movie).orEmpty()
_state.update {
@ -320,7 +306,6 @@ data class MovieState(
val similar: List<BaseItem> = emptyList(),
val discovered: List<DiscoverItem> = emptyList(),
/** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null,
val canDelete: Boolean = false,
) {

View file

@ -35,10 +35,10 @@ fun FocusedEpisodeHeader(
) {
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
ep?.ui?.quickDetails?.let {
ep?.let {
QuickDetails(
it,
ep.timeRemainingOrRuntime,
it.timeRemainingOrRuntime,
Modifier.padding(start = HeaderUtils.startPadding),
)
}

View file

@ -44,10 +44,8 @@ import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.studioNames
import com.github.damontecres.wholphin.data.model.withSourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards
@ -255,7 +253,6 @@ fun SeriesDetails(
}
},
discovered = state.discovered,
sourceRatings = state.sourceRatings,
onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination)
},
@ -333,7 +330,6 @@ fun SeriesDetailsContent(
extras: List<ExtrasItem>,
people: List<Person>,
discovered: List<DiscoverItem>,
sourceRatings: SourceRatings?,
played: Boolean,
favorite: Boolean,
canDelete: Boolean,
@ -381,7 +377,6 @@ fun SeriesDetailsContent(
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
overviewOnClick = overviewOnClick,
bringIntoViewRequester = bringIntoViewRequester,
sourceRatings = sourceRatings,
modifier =
Modifier
.fillMaxWidth()
@ -659,11 +654,9 @@ fun SeriesDetailsHeader(
overviewOnClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier,
sourceRatings: SourceRatings? = null,
) {
val scope = rememberCoroutineScope()
val dto = series.data
val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) }
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = modifier,
@ -681,7 +674,7 @@ fun SeriesDetailsHeader(
modifier = Modifier.fillMaxWidth(.60f),
) {
QuickDetails(
quickDetails,
series,
null,
Modifier.padding(start = HeaderUtils.startPadding),
)

View file

@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.ItemPlayback
import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.preferences.AppPreferences
import com.github.damontecres.wholphin.services.BackdropService
@ -21,7 +20,6 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.MediaManagementService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.PeopleFavorites
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.ServerReportService
import com.github.damontecres.wholphin.services.StreamChoiceService
@ -102,7 +100,6 @@ class SeriesViewModel
private val userPreferencesService: UserPreferencesService,
private val backdropService: BackdropService,
private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val mediaManagementService: MediaManagementService,
@Assisted val seriesId: UUID,
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
@ -221,7 +218,6 @@ class SeriesViewModel
seasons = seasons,
episodes = episodes,
trailers = remoteTrailers,
sourceRatings = ratingsService.cachedFor(series),
)
}
@ -255,11 +251,6 @@ class SeriesViewModel
_state.update { it.copy(similar = similar) }
}
}
viewModelScope.launchIO {
ratingsService.forItem(series).collect { ratings ->
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
}
}
viewModelScope.launchIO {
val results = seerrService.similar(series).orEmpty()
_state.update { it.copy(discovered = results) }
@ -905,6 +896,5 @@ data class SeriesState(
val discovered: List<DiscoverItem> = emptyList(),
val discoverSeries: DiscoverItem? = null,
/** Seerr ratings, null until they load or if unavailable */
val sourceRatings: SourceRatings? = null,
val chosenStreams: ChosenStreams? = null,
)

View file

@ -389,7 +389,7 @@ fun SeerrDiscoverPage(
modifier = modifier,
) {
val details =
remember(focusedItem, ratingMap) {
remember(focusedItem) {
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
buildList {
focusedItem.item
@ -398,17 +398,18 @@ fun SeerrDiscoverPage(
?.toString()
?.let(::add)
}.let {
val rating = ratingMap[focusedItem.item.type to focusedItem.id]
QuickDetailsData(listToDotString(it, rating))
QuickDetailsData(listToDotString(it))
}
}
}
val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] }
HomePageHeader(
title = focusedItem?.title?.getString(),
subtitle = focusedItem?.subtitle,
overview = focusedItem?.overview,
overviewTwoLines = false,
quickDetails = details,
ratings = focusedRatings,
timeRemaining = null,
endsAt = null,
showLogo = preferences.appPreferences.interfacePreferences.showLogos,

View file

@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
@ -72,6 +73,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
import com.github.damontecres.wholphin.ui.components.QuickDetails
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
import com.github.damontecres.wholphin.ui.components.rememberSourceRatings
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
@ -571,6 +573,7 @@ fun HomePageHeader(
overview = item?.data?.overview,
overviewTwoLines = isEpisode,
quickDetails = item?.ui?.quickDetails,
ratings = rememberSourceRatings(item),
timeRemaining = item?.timeRemainingOrRuntime,
endsAt = item?.data?.endDate,
showLogo = showLogo,
@ -586,6 +589,7 @@ fun HomePageHeader(
overview: String?,
overviewTwoLines: Boolean,
quickDetails: QuickDetailsData?,
ratings: SourceRatings?,
timeRemaining: Duration?,
endsAt: DateTime?,
showLogo: Boolean,
@ -611,7 +615,7 @@ fun HomePageHeader(
if (subtitle != null) {
EpisodeName(subtitle)
}
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt)
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings)
val overviewModifier =
Modifier
.padding(0.dp)

View file

@ -164,6 +164,7 @@
<string name="season_episode_number_padded">S%1$02dE%2$02d</string>
<string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string>
<string name="episode_number_short">E%1$d</string>
<string name="rating_episode_label">Ep</string>
<string name="season_number">Season %1$d</string>
<string name="tv_shows_title">TV Shows</string>
<string name="ui_interface">Interface</string>

View file

@ -1097,6 +1097,61 @@ components:
example: 0
description: Availability of the season in 4K
readOnly: true
SeasonDetails:
type: object
description: The answer of the season endpoint. Everything TMDB may not know is nullable.
properties:
id:
type: integer
airDate:
type: string
nullable: true
name:
type: string
nullable: true
overview:
type: string
nullable: true
posterPath:
type: string
nullable: true
seasonNumber:
type: integer
episodes:
type: array
items:
$ref: '#/components/schemas/SeasonEpisode'
SeasonEpisode:
type: object
description: An episode of the season endpoint. voteAverage is TMDB's user score as a decimal, 0 when nobody voted.
properties:
id:
type: integer
name:
type: string
nullable: true
overview:
type: string
nullable: true
airDate:
type: string
nullable: true
episodeNumber:
type: integer
seasonNumber:
type: integer
showId:
type: integer
stillPath:
type: string
nullable: true
voteAverage:
type: number
nullable: true
voteCount:
type: integer
format: int64
nullable: true
TvShowStatus:
type: string
enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"]
@ -6651,7 +6706,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Season'
$ref: '#/components/schemas/SeasonDetails'
/tv/{tvId}/recommendations:
get:
summary: Get recommended TV series

View file

@ -1,10 +1,8 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
@ -104,44 +102,103 @@ class SourceRatingsTest {
imdbVotes = 1,
)
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId })
assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge })
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId })
assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge })
}
private val jellyfinDetails =
QuickDetailsData(
basic = AnnotatedString("2020"),
communityRating = AnnotatedString("7.8 jellyfin"),
criticRating = AnnotatedString("59% jellyfin"),
@Test
fun everyRtMarkHasItsOwnBadge() {
val badges =
listOf(
RtMark.FRESH to RatingBadge.RT_FRESH,
RtMark.ROTTEN to RatingBadge.RT_ROTTEN,
RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT,
RtMark.SPILLED to RatingBadge.RT_SPILLED,
RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN,
)
badges.forEach { (mark, badge) ->
assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge)
}
}
private val movie =
SourceRatings.of(
rtCriticsScore = 96,
rtCriticsLabel = "Certified Fresh",
rtAudienceScore = 59,
rtAudienceLabel = "Spilled",
imdbScore = 8.8,
imdbVotes = 10,
)
@Test
fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() {
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null))
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings()))
fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() {
// Rotten Tomatoes (critic rating setting) before IMDb (community rating setting)
assertEquals(
listOf("96%", "59%", "8.8"),
movie.visibleEntries(community = true, critic = true).map { it.text },
)
assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text })
assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text })
assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty())
}
@Test
fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() {
val details =
jellyfinDetails.withSourceRatings(
SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10),
)
// COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes
assertTrue(details.communityRating!!.text.endsWith("8.8"))
assertTrue(details.criticRating!!.text.endsWith("96%"))
assertEquals(jellyfinDetails.basic, details.basic)
// PWA order: Rotten Tomatoes before IMDb
assertTrue(details.criticRatingFirst)
assertFalse(jellyfinDetails.criticRatingFirst)
fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() {
val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true)
assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge })
assertEquals(listOf("7.6"), entries.map { it.text })
}
@Test
fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() {
// Seerr has only RT: the Jellyfin star is not shown next to it
val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh"))
assertNull(details.communityRating)
assertTrue(details.criticRating!!.text.endsWith("50%"))
fun noEntriesWithoutRatings() {
assertTrue(SourceRatings().isEmpty)
assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty())
assertFalse(movie.isEmpty)
}
@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)
}
}

View file

@ -0,0 +1,426 @@
@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<MoviesApi>()
private val tvApi = mockk<TvApi>()
private val seerrApi =
mockk<SeerrApiClient> {
every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi
every { tvApi } returns this@SeerrRatingsItemsTest.tvApi
}
private val active = MutableStateFlow(true)
private val seerrService =
mockk<SeerrService> {
every { api } returns seerrApi
every { this@mockk.active } returns this@SeerrRatingsItemsTest.active
}
private val userLibraryApi = mockk<UserLibraryApi>()
private val jellyfin =
mockk<ApiClient> {
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<String, String>? = 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<Int, Double?, Long?>) =
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()) }
}
}

View file

@ -21,6 +21,7 @@ import io.mockk.mockk
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.junit.Assert.assertEquals
@ -45,7 +46,7 @@ class SeerrRatingsServiceTest {
every { api } returns apiClient
every { active } answers { activeFlow }
}
private val service = SeerrRatingsService(seerrService)
private val service = SeerrRatingsService(seerrService, mockk<ApiClient>())
private fun movieDetails(
average: Double?,
@ -253,8 +254,7 @@ class SeerrRatingsServiceTest {
// no TMDB id
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
// not a movie/series
assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first())
// not something that has ratings
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
// call fails
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())

View file

@ -0,0 +1,281 @@
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<MoviesApi>()
private val tvApi = mockk<TvApi>()
private val seerrApi =
mockk<SeerrApiClient> {
every { moviesApi } returns this@SourceRatingsRowTest.moviesApi
every { tvApi } returns this@SourceRatingsRowTest.tvApi
}
private val seerrService =
mockk<SeerrService> {
every { api } returns seerrApi
every { active } returns flowOf(true)
}
private val userLibraryApi = mockk<UserLibraryApi>()
private val jellyfin = mockk<ApiClient> { 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<DisplayToggle> = 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)
}
}