Show PWA source ratings on every surface through one shared rating row #26
28 changed files with 1361 additions and 567 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ fun CollectionFolderListDetails(
|
|||
)
|
||||
|
||||
item?.let {
|
||||
QuickDetails(item.ui.quickDetails, null)
|
||||
QuickDetails(item, null)
|
||||
}
|
||||
|
||||
OverviewText(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -339,7 +339,6 @@ fun MovieDetailsContent(
|
|||
chosenStreams = state.chosenStreams,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
overviewOnClick = overviewOnClick,
|
||||
sourceRatings = state.sourceRatings,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue