Show PWA source ratings (RT, IMDb, TMDB from Seerr) on the TV #24
25 changed files with 1191 additions and 182 deletions
|
|
@ -371,6 +371,10 @@ data class QuickDetailsData(
|
|||
val officialRating: AnnotatedString? = null,
|
||||
val criticRating: AnnotatedString? = null,
|
||||
val communityRating: AnnotatedString? = null,
|
||||
/**
|
||||
* Show [criticRating] (Rotten Tomatoes) before [communityRating] (IMDb/TMDB) like the PWA, instead of the Jellyfin order
|
||||
*/
|
||||
val criticRatingFirst: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.ui.detail.CardGridItem
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.util.LocalDateSerializer
|
||||
|
|
@ -123,20 +121,3 @@ data class DiscoverItem(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A rating for a discovered item which is usually fetched separately from the item
|
||||
*/
|
||||
data class DiscoverRating(
|
||||
val criticRating: Int?,
|
||||
val audienceRating: Float?,
|
||||
) {
|
||||
constructor(rating: MovieMovieIdRatingsGet200Response) : this(
|
||||
criticRating = rating.criticsScore,
|
||||
audienceRating = rating.audienceScore?.div(10f),
|
||||
)
|
||||
constructor(rating: TvTvIdRatingsGet200Response) : this(
|
||||
criticRating = rating.criticsScore,
|
||||
audienceRating = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage.
|
||||
*/
|
||||
enum class RtMark {
|
||||
FRESH,
|
||||
ROTTEN,
|
||||
UPRIGHT,
|
||||
SPILLED,
|
||||
|
||||
/** Seerr sent a score without a recognised label */
|
||||
UNKNOWN,
|
||||
;
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null
|
||||
*/
|
||||
fun fromLabel(label: String?): RtMark? =
|
||||
when (label) {
|
||||
"Certified Fresh", "Fresh" -> FRESH
|
||||
"Rotten" -> ROTTEN
|
||||
"Upright" -> UPRIGHT
|
||||
"Spilled" -> SPILLED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Immutable
|
||||
data class RtScore(
|
||||
/** 0-100 */
|
||||
val percent: Int,
|
||||
val mark: RtMark,
|
||||
)
|
||||
|
||||
/**
|
||||
* One displayable rating
|
||||
*/
|
||||
@Immutable
|
||||
sealed interface RatingEntry {
|
||||
val text: String
|
||||
|
||||
/**
|
||||
* The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge
|
||||
*/
|
||||
val inlineContentId: String
|
||||
|
||||
data class RottenTomatoes(
|
||||
val score: RtScore,
|
||||
/** Critics (Tomatometer) or audience (Popcornmeter) */
|
||||
val critics: Boolean,
|
||||
) : RatingEntry {
|
||||
override val text: String get() = "${score.percent}%"
|
||||
override val inlineContentId: String
|
||||
get() =
|
||||
when (score.mark) {
|
||||
RtMark.FRESH -> INLINE_RT_FRESH
|
||||
RtMark.ROTTEN -> INLINE_RT_ROTTEN
|
||||
RtMark.UPRIGHT -> INLINE_RT_UPRIGHT
|
||||
RtMark.SPILLED -> INLINE_RT_SPILLED
|
||||
RtMark.UNKNOWN -> INLINE_RT_UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
data class Imdb(
|
||||
val score: Double,
|
||||
) : RatingEntry {
|
||||
override val text: String get() = formatScore(score)
|
||||
override val inlineContentId: String get() = INLINE_IMDB
|
||||
}
|
||||
|
||||
data class Tmdb(
|
||||
val score: Double,
|
||||
) : RatingEntry {
|
||||
override val text: String get() = formatScore(score)
|
||||
override val inlineContentId: String get() = INLINE_TMDB
|
||||
}
|
||||
}
|
||||
|
||||
const val INLINE_RT_FRESH = "fresh"
|
||||
const val INLINE_RT_ROTTEN = "rotten"
|
||||
const val INLINE_RT_UPRIGHT = "rt_upright"
|
||||
const val INLINE_RT_SPILLED = "rt_spilled"
|
||||
const val INLINE_RT_UNKNOWN = "rt_unknown"
|
||||
const val INLINE_IMDB = "imdb"
|
||||
const val INLINE_TMDB = "tmdb"
|
||||
|
||||
private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
|
||||
|
||||
/**
|
||||
* Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience,
|
||||
* IMDb (movies only), and TMDB.
|
||||
*
|
||||
* Only scores that were actually observed are present. Use [of] to apply the PWA's rules.
|
||||
*/
|
||||
@Immutable
|
||||
data class SourceRatings(
|
||||
val rtCritics: RtScore? = null,
|
||||
val rtAudience: RtScore? = null,
|
||||
val imdb: Double? = null,
|
||||
val tmdb: Double? = null,
|
||||
) {
|
||||
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null
|
||||
|
||||
/**
|
||||
* Rotten Tomatoes entries in PWA order: critics then audience
|
||||
*/
|
||||
val criticEntries: List<RatingEntry.RottenTomatoes>
|
||||
get() =
|
||||
listOfNotNull(
|
||||
rtCritics?.let { RatingEntry.RottenTomatoes(it, true) },
|
||||
rtAudience?.let { RatingEntry.RottenTomatoes(it, false) },
|
||||
)
|
||||
|
||||
/**
|
||||
* IMDb or TMDB
|
||||
*/
|
||||
val communityEntries: List<RatingEntry>
|
||||
get() =
|
||||
listOfNotNull(
|
||||
imdb?.let { RatingEntry.Imdb(it) },
|
||||
tmdb?.let { RatingEntry.Tmdb(it) },
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val MAX_PERCENT = 100.0
|
||||
const val MAX_SCORE = 10.0
|
||||
|
||||
/**
|
||||
* Builds ratings using the PWA's rules:
|
||||
* - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped
|
||||
* - A zero-vote IMDb/TMDB score is unrated, never 0
|
||||
* - RT icon comes from the label Seerr reports
|
||||
* - TMDB is kept only when no other source has a score
|
||||
*
|
||||
* @param rtCriticsLabel Seerr's critics status: Certified Fresh, Fresh, or Rotten
|
||||
* @param rtAudienceLabel Seerr's audience status: Upright or Spilled
|
||||
*/
|
||||
fun of(
|
||||
rtCriticsScore: Number? = null,
|
||||
rtCriticsLabel: String? = null,
|
||||
rtAudienceScore: Number? = null,
|
||||
rtAudienceLabel: String? = null,
|
||||
imdbScore: Number? = null,
|
||||
imdbVotes: Long? = null,
|
||||
tmdbScore: Number? = null,
|
||||
tmdbVotes: Long? = null,
|
||||
): SourceRatings {
|
||||
val critics = rtScore(rtCriticsScore, rtCriticsLabel, RtMark.UNKNOWN)
|
||||
val audience = rtScore(rtAudienceScore, rtAudienceLabel, RtMark.UPRIGHT)
|
||||
val imdb = userScore(imdbScore, imdbVotes)
|
||||
val tmdb = userScore(tmdbScore, tmdbVotes)
|
||||
return if (critics != null || audience != null || imdb != null) {
|
||||
SourceRatings(critics, audience, imdb, null)
|
||||
} else {
|
||||
SourceRatings(tmdb = tmdb)
|
||||
}
|
||||
}
|
||||
|
||||
private fun rtScore(
|
||||
score: Number?,
|
||||
label: String?,
|
||||
defaultMark: RtMark,
|
||||
): RtScore? {
|
||||
val value = score?.toDouble() ?: return null
|
||||
if (value.isNaN() || value < 0.0 || value > MAX_PERCENT) return null
|
||||
return RtScore(value.toInt(), RtMark.fromLabel(label) ?: defaultMark)
|
||||
}
|
||||
|
||||
private fun userScore(
|
||||
score: Number?,
|
||||
votes: Long?,
|
||||
): Double? {
|
||||
val value = score?.toDouble() ?: return null
|
||||
if (value.isNaN() || value < 0.0 || value > MAX_SCORE) return null
|
||||
if (votes != null && votes <= 0L) return null
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends each entry as a dot, its badge, and its score
|
||||
*/
|
||||
fun AnnotatedString.Builder.appendRatings(entries: List<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,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The TMDB user vote average & count as reported by Seerr's movie/TV details
|
||||
*/
|
||||
data class TmdbVote(
|
||||
val average: Double?,
|
||||
val count: Long?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses
|
||||
*
|
||||
* Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from
|
||||
* `voteAverage`/`voteCount` and is only requested when no other source has a score.
|
||||
*
|
||||
* Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Singleton
|
||||
class SeerrRatingsService
|
||||
@Inject
|
||||
constructor(
|
||||
private val seerrService: SeerrService,
|
||||
) {
|
||||
private data class Key(
|
||||
val type: SeerrItemType,
|
||||
val tmdbId: Int,
|
||||
)
|
||||
|
||||
private val cache =
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(CACHE_SIZE)
|
||||
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
|
||||
.build<Key, SourceRatings>()
|
||||
|
||||
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 already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it
|
||||
* doesn't show the Jellyfin values first
|
||||
*/
|
||||
fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) }
|
||||
|
||||
/**
|
||||
* Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the
|
||||
* connection is established asynchronously after login.
|
||||
*
|
||||
* Emits null while Seerr is not active, the item isn't a movie/series with a TMDB id, or the lookup failed.
|
||||
* Callers should keep showing the Jellyfin values for null.
|
||||
*/
|
||||
fun forItem(item: BaseItem): Flow<SourceRatings?> {
|
||||
val (type, tmdbId) = target(item) ?: return flowOf(null)
|
||||
return seerrService.active
|
||||
.distinctUntilChanged()
|
||||
.mapLatest { active -> if (active) get(type, tmdbId) else null }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request
|
||||
* @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now
|
||||
*/
|
||||
suspend fun get(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
knownTmdb: TmdbVote? = null,
|
||||
): SourceRatings? {
|
||||
val key = Key(type, tmdbId)
|
||||
cache.getIfPresent(key)?.let { return it }
|
||||
if (type != SeerrItemType.MOVIE && type != SeerrItemType.TV) return null
|
||||
|
||||
val combined =
|
||||
try {
|
||||
fetchCombined(type, tmdbId)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error fetching ratings for %s %s", type, tmdbId)
|
||||
return null
|
||||
}
|
||||
var ratings = build(combined, null)
|
||||
if (ratings.isEmpty) {
|
||||
// Nothing else has a score, so TMDB is the only possible source
|
||||
val vote =
|
||||
knownTmdb ?: try {
|
||||
fetchTmdb(type, tmdbId)
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
Timber.w(ex, "Error fetching TMDB rating for %s %s", type, tmdbId)
|
||||
return null
|
||||
}
|
||||
ratings = build(combined, vote)
|
||||
}
|
||||
cache.put(key, ratings)
|
||||
return ratings
|
||||
}
|
||||
|
||||
private suspend fun fetchCombined(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
): Combined =
|
||||
try {
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> {
|
||||
Combined.Movie(seerrService.api.moviesApi.movieMovieIdRatingscombinedGet(movieId = tmdbId))
|
||||
}
|
||||
|
||||
else -> {
|
||||
Combined.Tv(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = tmdbId))
|
||||
}
|
||||
}
|
||||
} catch (ex: ClientException) {
|
||||
if (ex.statusCode == 404) {
|
||||
// Seerr has no RT/IMDb data for this title, that is an answer rather than a failure
|
||||
Timber.d("No ratings for %s %s", type, tmdbId)
|
||||
Combined.None
|
||||
} else {
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchTmdb(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
): TmdbVote =
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> {
|
||||
seerrService.api.moviesApi
|
||||
.movieMovieIdGet(movieId = tmdbId)
|
||||
.let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) }
|
||||
}
|
||||
|
||||
else -> {
|
||||
seerrService.api.tvApi
|
||||
.tvTvIdGet(tvId = tmdbId)
|
||||
.let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(
|
||||
combined: Combined,
|
||||
tmdb: TmdbVote?,
|
||||
): SourceRatings =
|
||||
when (combined) {
|
||||
is Combined.Movie -> buildMovie(combined.response, tmdb)
|
||||
is Combined.Tv -> {
|
||||
val rt = combined.response
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = rt.criticsScore,
|
||||
rtCriticsLabel = rt.criticsRating,
|
||||
rtAudienceScore = rt.audienceScore,
|
||||
rtAudienceLabel = rt.audienceRating,
|
||||
tmdbScore = tmdb?.average,
|
||||
tmdbVotes = tmdb?.count,
|
||||
)
|
||||
}
|
||||
|
||||
Combined.None -> {
|
||||
SourceRatings.of(tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMovie(
|
||||
response: MovieMovieIdRatingscombinedGet200Response,
|
||||
tmdb: TmdbVote?,
|
||||
): SourceRatings =
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = response.rt?.criticsScore,
|
||||
rtCriticsLabel = response.rt?.criticsRating,
|
||||
rtAudienceScore = response.rt?.audienceScore,
|
||||
rtAudienceLabel = response.rt?.audienceRating,
|
||||
imdbScore = response.imdb?.criticsScore,
|
||||
imdbVotes = response.imdb?.criticsScoreCount,
|
||||
tmdbScore = tmdb?.average,
|
||||
tmdbVotes = tmdb?.count,
|
||||
)
|
||||
|
||||
private sealed interface Combined {
|
||||
data class Movie(
|
||||
val response: MovieMovieIdRatingscombinedGet200Response,
|
||||
) : Combined
|
||||
|
||||
data class Tv(
|
||||
val response: TvTvIdRatingsGet200Response,
|
||||
) : Combined
|
||||
|
||||
data object None : Combined
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CACHE_SIZE = 300L
|
||||
private const val CACHE_HOURS = 6L
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,12 @@ import android.content.Context
|
|||
import android.content.res.Resources
|
||||
import android.text.format.DateFormat
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.WholphinApplication
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.appendRatings
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||
|
|
@ -234,27 +235,16 @@ fun AnnotatedString.Builder.dot() = append(" \u2022 ")
|
|||
|
||||
fun listToDotString(
|
||||
strings: List<String>,
|
||||
communityRating: Float?,
|
||||
criticRating: Float?,
|
||||
ratings: SourceRatings?,
|
||||
): AnnotatedString =
|
||||
buildAnnotatedString {
|
||||
strings.forEachIndexed { index, string ->
|
||||
append(string)
|
||||
if (index != strings.lastIndex) dot()
|
||||
}
|
||||
communityRating?.let {
|
||||
dot()
|
||||
append(String.format(Locale.getDefault(), "%.1f", it))
|
||||
appendInlineContent(id = "star")
|
||||
}
|
||||
criticRating?.let {
|
||||
dot()
|
||||
append("${it.toInt()}%")
|
||||
if (it >= 60f) {
|
||||
appendInlineContent(id = "fresh")
|
||||
} else {
|
||||
appendInlineContent(id = "rotten")
|
||||
}
|
||||
ratings?.let {
|
||||
appendRatings(it.criticEntries)
|
||||
appendRatings(it.communityEntries)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
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
|
||||
|
|
@ -10,7 +12,9 @@ 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.Color
|
||||
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
|
||||
|
|
@ -23,6 +27,13 @@ import androidx.tv.material3.Icon
|
|||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_IMDB
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_RT_FRESH
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_RT_ROTTEN
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_RT_SPILLED
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_RT_UNKNOWN
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_RT_UPRIGHT
|
||||
import com.github.damontecres.wholphin.data.model.INLINE_TMDB
|
||||
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
||||
import com.github.damontecres.wholphin.ui.dot
|
||||
|
|
@ -48,11 +59,14 @@ fun QuickDetails(
|
|||
if (DisplayToggle.OFFICIAL_RATING in enabled) {
|
||||
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap)
|
||||
}
|
||||
if (DisplayToggle.COMMUNITY_RATING in enabled) {
|
||||
QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
|
||||
}
|
||||
if (DisplayToggle.CRITIC_RATING in enabled) {
|
||||
QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
|
||||
val community = DisplayToggle.COMMUNITY_RATING in enabled
|
||||
val critic = DisplayToggle.CRITIC_RATING in enabled
|
||||
if (details.criticRatingFirst) {
|
||||
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
|
||||
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
|
||||
} else {
|
||||
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
|
||||
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
|
||||
}
|
||||
}
|
||||
if (timeRemaining != null) {
|
||||
|
|
@ -84,15 +98,26 @@ fun QuickDetailsText(
|
|||
@Composable
|
||||
fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typography.titleSmall) =
|
||||
remember(textStyle) {
|
||||
val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
|
||||
// Wordmark badges are wider than tall, like in the PWA
|
||||
val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
|
||||
fun badge(
|
||||
placeholder: Placeholder,
|
||||
@DrawableRes resId: Int,
|
||||
muted: Boolean = false,
|
||||
) = InlineTextContent(placeholder) {
|
||||
Image(
|
||||
painter = painterResource(resId),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
alpha = if (muted) 0.55f else 1f,
|
||||
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
mapOf(
|
||||
"star" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
InlineTextContent(square) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Star,
|
||||
tint = FilledStarColor,
|
||||
|
|
@ -100,36 +125,13 @@ fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typograp
|
|||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
},
|
||||
"rotten" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
"fresh" to
|
||||
InlineTextContent(
|
||||
Placeholder(
|
||||
textStyle.fontSize,
|
||||
textStyle.fontSize,
|
||||
PlaceholderVerticalAlign.TextCenter,
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
tint = Color.Unspecified,
|
||||
)
|
||||
},
|
||||
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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import androidx.tv.material3.Text
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.LocalTrailer
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
|
|
@ -185,7 +185,7 @@ fun DiscoverMovieDetailsContent(
|
|||
preferences: UserPreferences,
|
||||
userConfig: SeerrUserConfig?,
|
||||
movie: MovieDetails,
|
||||
rating: DiscoverRating?,
|
||||
rating: SourceRatings?,
|
||||
canCancel: Boolean,
|
||||
people: List<DiscoverItem>,
|
||||
trailers: List<Trailer>,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import androidx.tv.material3.MaterialTheme
|
|||
import androidx.tv.material3.Text
|
||||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.jellyfinId
|
||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||
|
|
@ -44,7 +44,7 @@ import kotlin.time.Duration.Companion.minutes
|
|||
fun DiscoverMovieDetailsHeader(
|
||||
preferences: UserPreferences,
|
||||
movie: MovieDetails,
|
||||
rating: DiscoverRating?,
|
||||
rating: SourceRatings?,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
overviewOnClick: () -> Unit,
|
||||
showLogo: Boolean,
|
||||
|
|
@ -107,8 +107,7 @@ fun DiscoverMovieDetailsHeader(
|
|||
}.let {
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
rating,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
|
|||
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.RequestStatus
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
|
|
@ -19,9 +19,11 @@ import com.github.damontecres.wholphin.data.model.Trailer
|
|||
import com.github.damontecres.wholphin.data.model.hasPermission
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.services.SeerrUserConfig
|
||||
import com.github.damontecres.wholphin.services.TmdbVote
|
||||
import com.github.damontecres.wholphin.ui.equalsNotNull
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
|
|
@ -60,6 +62,7 @@ class DiscoverMovieViewModel
|
|||
private val backdropService: BackdropService,
|
||||
val serverRepository: ServerRepository,
|
||||
val seerrService: SeerrService,
|
||||
private val ratingsService: SeerrRatingsService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
@Assisted val item: DiscoverItem,
|
||||
) : ViewModel() {
|
||||
|
|
@ -106,11 +109,11 @@ class DiscoverMovieViewModel
|
|||
|
||||
viewModelScope.launchIO {
|
||||
val rating =
|
||||
getDiscoverRating(item.id) {
|
||||
DiscoverRating(
|
||||
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id),
|
||||
)
|
||||
}
|
||||
ratingsService.get(
|
||||
SeerrItemType.MOVIE,
|
||||
item.id,
|
||||
TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()),
|
||||
)
|
||||
_state.update { it.copy(rating = rating) }
|
||||
}
|
||||
if (state.value.similar.isEmpty()) {
|
||||
|
|
@ -276,7 +279,7 @@ fun canUserCancelRequest(
|
|||
|
||||
data class DiscoverMovieState(
|
||||
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
|
||||
val rating: DiscoverRating? = null,
|
||||
val rating: SourceRatings? = null,
|
||||
val seasons: List<RequestSeason> = emptyList(),
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
val people: List<DiscoverItem> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.R
|
|||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
|
|
@ -211,7 +211,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
preferences: UserPreferences,
|
||||
userConfig: SeerrUserConfig?,
|
||||
series: TvDetails,
|
||||
rating: DiscoverRating?,
|
||||
rating: SourceRatings?,
|
||||
canCancel: Boolean,
|
||||
seasons: List<RequestSeason>,
|
||||
similar: List<DiscoverItem>,
|
||||
|
|
@ -422,7 +422,7 @@ fun DiscoverSeriesDetailsContent(
|
|||
@Composable
|
||||
fun DiscoverSeriesDetailsHeader(
|
||||
series: TvDetails,
|
||||
rating: DiscoverRating?,
|
||||
rating: SourceRatings?,
|
||||
overviewOnClick: () -> Unit,
|
||||
showLogo: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
@ -466,8 +466,7 @@ fun DiscoverSeriesDetailsHeader(
|
|||
}.let {
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
rating,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutReques
|
|||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.data.ServerRepository
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.RemoteTrailer
|
||||
import com.github.damontecres.wholphin.data.model.RequestStatus
|
||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||
|
|
@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
|
|||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrServerRepository
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.services.TmdbVote
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.launchDefault
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
|
|
@ -60,6 +62,7 @@ class DiscoverSeriesViewModel
|
|||
private val backdropService: BackdropService,
|
||||
val serverRepository: ServerRepository,
|
||||
val seerrService: SeerrService,
|
||||
private val ratingsService: SeerrRatingsService,
|
||||
private val seerrServerRepository: SeerrServerRepository,
|
||||
@Assisted val item: DiscoverItem,
|
||||
) : ViewModel() {
|
||||
|
|
@ -109,9 +112,11 @@ class DiscoverSeriesViewModel
|
|||
|
||||
viewModelScope.launchIO {
|
||||
val rating =
|
||||
getDiscoverRating(item.id) {
|
||||
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
|
||||
}
|
||||
ratingsService.get(
|
||||
SeerrItemType.TV,
|
||||
item.id,
|
||||
TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()),
|
||||
)
|
||||
_state.update { it.copy(rating = rating) }
|
||||
}
|
||||
if (state.value.similar.isEmpty()) {
|
||||
|
|
@ -416,7 +421,7 @@ class DiscoverSeriesViewModel
|
|||
|
||||
data class DiscoverSeriesState(
|
||||
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
|
||||
val rating: DiscoverRating? = null,
|
||||
val rating: SourceRatings? = null,
|
||||
val seasons: List<RequestSeason> = emptyList(),
|
||||
val seasons4k: List<RequestSeason> = emptyList(),
|
||||
val trailers: List<Trailer> = emptyList(),
|
||||
|
|
|
|||
|
|
@ -1,39 +1,16 @@
|
|||
package com.github.damontecres.wholphin.ui.detail.discover
|
||||
|
||||
import android.content.Context
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.jellyfinId
|
||||
import com.github.damontecres.wholphin.services.jellyfinIdAsString
|
||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||
import com.github.damontecres.wholphin.ui.showToast
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import timber.log.Timber
|
||||
|
||||
suspend fun getDiscoverRating(
|
||||
id: Int,
|
||||
func: suspend () -> DiscoverRating,
|
||||
): DiscoverRating? =
|
||||
try {
|
||||
func.invoke()
|
||||
} catch (ex: CancellationException) {
|
||||
throw ex
|
||||
} catch (ex: ClientException) {
|
||||
if (ex.statusCode == 404) {
|
||||
Timber.w("No rating for %s", id)
|
||||
} else {
|
||||
Timber.e(ex, "Error fetching rating for %s", id)
|
||||
}
|
||||
null
|
||||
} catch (ex: Exception) {
|
||||
Timber.e(ex, "Error fetching rating for %s", id)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId]
|
||||
*
|
||||
|
|
|
|||
|
|
@ -339,6 +339,7 @@ fun MovieDetailsContent(
|
|||
chosenStreams = state.chosenStreams,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
overviewOnClick = overviewOnClick,
|
||||
sourceRatings = state.sourceRatings,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ import androidx.tv.material3.Text
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.withSourceRatings
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
|
|
@ -41,8 +43,10 @@ fun MovieDetailsHeader(
|
|||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
overviewOnClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sourceRatings: SourceRatings? = null,
|
||||
) {
|
||||
val dto = movie.data
|
||||
val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) }
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
Column(
|
||||
|
|
@ -64,7 +68,7 @@ fun MovieDetailsHeader(
|
|||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
QuickDetails(
|
||||
movie.ui.quickDetails,
|
||||
quickDetails,
|
||||
movie.timeRemainingOrRuntime,
|
||||
Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
|||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.ExtrasService
|
||||
|
|
@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
|||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.services.ServerReportService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
|
|
@ -57,6 +59,7 @@ class MovieViewModel
|
|||
constructor(
|
||||
private val api: ApiClient,
|
||||
private val seerrService: SeerrService,
|
||||
private val ratingsService: SeerrRatingsService,
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val navigationManager: NavigationManager,
|
||||
val serverRepository: ServerRepository,
|
||||
|
|
@ -100,6 +103,8 @@ class MovieViewModel
|
|||
return item
|
||||
}
|
||||
|
||||
private var ratingsJob: Job? = null
|
||||
|
||||
fun init(): Job =
|
||||
viewModelScope.launchDefault {
|
||||
val movie =
|
||||
|
|
@ -124,6 +129,7 @@ class MovieViewModel
|
|||
chosenStreams = chosenStreams,
|
||||
trailers = remoteTrailers,
|
||||
chapters = chapters,
|
||||
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
|
||||
)
|
||||
}
|
||||
backdropService.submit(movie)
|
||||
|
|
@ -152,6 +158,14 @@ class MovieViewModel
|
|||
)
|
||||
}
|
||||
}
|
||||
// init() runs again on resume and the flow never completes, so don't stack collectors
|
||||
ratingsJob?.cancel()
|
||||
ratingsJob =
|
||||
viewModelScope.launchIO {
|
||||
ratingsService.forItem(movie).collect { ratings ->
|
||||
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(movie).orEmpty()
|
||||
_state.update {
|
||||
|
|
@ -305,6 +319,8 @@ data class MovieState(
|
|||
val extras: List<ExtrasItem> = emptyList(),
|
||||
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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -44,8 +44,10 @@ import com.github.damontecres.wholphin.data.ExtrasItem
|
|||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.data.model.studioNames
|
||||
import com.github.damontecres.wholphin.data.model.withSourceRatings
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.TrailerService
|
||||
import com.github.damontecres.wholphin.ui.Cards
|
||||
|
|
@ -253,6 +255,7 @@ fun SeriesDetails(
|
|||
}
|
||||
},
|
||||
discovered = state.discovered,
|
||||
sourceRatings = state.sourceRatings,
|
||||
onClickDiscover = { index, item ->
|
||||
viewModel.navigateTo(item.destination)
|
||||
},
|
||||
|
|
@ -330,6 +333,7 @@ fun SeriesDetailsContent(
|
|||
extras: List<ExtrasItem>,
|
||||
people: List<Person>,
|
||||
discovered: List<DiscoverItem>,
|
||||
sourceRatings: SourceRatings?,
|
||||
played: Boolean,
|
||||
favorite: Boolean,
|
||||
canDelete: Boolean,
|
||||
|
|
@ -377,6 +381,7 @@ fun SeriesDetailsContent(
|
|||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||
overviewOnClick = overviewOnClick,
|
||||
bringIntoViewRequester = bringIntoViewRequester,
|
||||
sourceRatings = sourceRatings,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
|
|
@ -654,9 +659,11 @@ fun SeriesDetailsHeader(
|
|||
overviewOnClick: () -> Unit,
|
||||
bringIntoViewRequester: BringIntoViewRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
sourceRatings: SourceRatings? = null,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val dto = series.data
|
||||
val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) }
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = modifier,
|
||||
|
|
@ -674,7 +681,7 @@ fun SeriesDetailsHeader(
|
|||
modifier = Modifier.fillMaxWidth(.60f),
|
||||
) {
|
||||
QuickDetails(
|
||||
series.ui.quickDetails,
|
||||
quickDetails,
|
||||
null,
|
||||
Modifier.padding(start = HeaderUtils.startPadding),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
|||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||
import com.github.damontecres.wholphin.data.model.Person
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.data.model.Trailer
|
||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
|
|
@ -20,6 +21,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
|||
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.services.ServerReportService
|
||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||
|
|
@ -100,6 +102,7 @@ class SeriesViewModel
|
|||
private val userPreferencesService: UserPreferencesService,
|
||||
private val backdropService: BackdropService,
|
||||
private val seerrService: SeerrService,
|
||||
private val ratingsService: SeerrRatingsService,
|
||||
private val mediaManagementService: MediaManagementService,
|
||||
@Assisted val seriesId: UUID,
|
||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||
|
|
@ -218,6 +221,7 @@ class SeriesViewModel
|
|||
seasons = seasons,
|
||||
episodes = episodes,
|
||||
trailers = remoteTrailers,
|
||||
sourceRatings = ratingsService.cachedFor(series),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -251,6 +255,11 @@ class SeriesViewModel
|
|||
_state.update { it.copy(similar = similar) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
ratingsService.forItem(series).collect { ratings ->
|
||||
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launchIO {
|
||||
val results = seerrService.similar(series).orEmpty()
|
||||
_state.update { it.copy(discovered = results) }
|
||||
|
|
@ -895,5 +904,7 @@ data class SeriesState(
|
|||
val peopleInEpisode: PeopleInItem = PeopleInItem(),
|
||||
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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,17 +32,17 @@ import androidx.lifecycle.viewModelScope
|
|||
import com.github.damontecres.wholphin.R
|
||||
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||
import com.github.damontecres.wholphin.data.model.DiscoverRating
|
||||
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||
import com.github.damontecres.wholphin.services.BackdropService
|
||||
import com.github.damontecres.wholphin.services.NavigationManager
|
||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||
import com.github.damontecres.wholphin.services.SeerrService
|
||||
import com.github.damontecres.wholphin.ui.components.Genre
|
||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||
import com.github.damontecres.wholphin.ui.data.RowColumn
|
||||
import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating
|
||||
import com.github.damontecres.wholphin.ui.launchIO
|
||||
import com.github.damontecres.wholphin.ui.listToDotString
|
||||
import com.github.damontecres.wholphin.ui.main.HomePageHeader
|
||||
|
|
@ -57,9 +57,9 @@ import com.github.damontecres.wholphin.ui.util.StringProvider
|
|||
import com.github.damontecres.wholphin.util.DataLoadingState
|
||||
import com.github.damontecres.wholphin.util.DiscoverRequestType
|
||||
import com.github.damontecres.wholphin.util.successValue
|
||||
import com.google.common.cache.CacheBuilder
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.model.UUID
|
||||
|
|
@ -73,11 +73,12 @@ class SeerrDiscoverViewModel
|
|||
constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val seerrService: SeerrService,
|
||||
private val ratingsService: SeerrRatingsService,
|
||||
val navigationManager: NavigationManager,
|
||||
private val backdropService: BackdropService,
|
||||
) : ViewModel() {
|
||||
val state = MutableStateFlow<DiscoverState>(DiscoverState())
|
||||
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
|
||||
val rating = MutableStateFlow<Map<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf())
|
||||
|
||||
init {
|
||||
viewModelScope.launchIO {
|
||||
|
|
@ -246,50 +247,23 @@ class SeerrDiscoverViewModel
|
|||
}
|
||||
}
|
||||
|
||||
private val ratingCache =
|
||||
CacheBuilder
|
||||
.newBuilder()
|
||||
.maximumSize(100)
|
||||
.build<Int, DiscoverRating>()
|
||||
private var ratingJob: Job? = null
|
||||
|
||||
// TODO this is not very efficient
|
||||
/**
|
||||
* Fetch the ratings for the focused item, cancelling any previous request that is still running
|
||||
*/
|
||||
fun fetchRating(
|
||||
id: Int,
|
||||
type: SeerrItemType,
|
||||
) {
|
||||
viewModelScope.launchIO {
|
||||
val cachedResult = ratingCache.getIfPresent(id)
|
||||
if (cachedResult != null) {
|
||||
return@launchIO
|
||||
val key = type to id
|
||||
if (key in rating.value) return
|
||||
ratingJob?.cancel()
|
||||
ratingJob =
|
||||
viewModelScope.launchIO {
|
||||
val result = ratingsService.get(type, id) ?: return@launchIO
|
||||
rating.update { it + (key to result) }
|
||||
}
|
||||
val result =
|
||||
getDiscoverRating(id) {
|
||||
when (type) {
|
||||
SeerrItemType.MOVIE -> {
|
||||
DiscoverRating(
|
||||
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id),
|
||||
)
|
||||
}
|
||||
|
||||
SeerrItemType.TV -> {
|
||||
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id))
|
||||
}
|
||||
|
||||
SeerrItemType.PERSON -> {
|
||||
DiscoverRating(null, null)
|
||||
}
|
||||
|
||||
SeerrItemType.UNKNOWN -> {
|
||||
DiscoverRating(null, null)
|
||||
}
|
||||
}
|
||||
} ?: DiscoverRating(null, null)
|
||||
|
||||
ratingCache.put(id, result)
|
||||
rating.update {
|
||||
ratingCache.asMap().toMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -424,14 +398,8 @@ fun SeerrDiscoverPage(
|
|||
?.toString()
|
||||
?.let(::add)
|
||||
}.let {
|
||||
val rating = focusedItem.id.let { ratingMap[it] }
|
||||
val str =
|
||||
listToDotString(
|
||||
it,
|
||||
rating?.audienceRating,
|
||||
rating?.criticRating?.toFloat(),
|
||||
)
|
||||
QuickDetailsData(str)
|
||||
val rating = ratingMap[focusedItem.item.type to focusedItem.id]
|
||||
QuickDetailsData(listToDotString(it, rating))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
app/src/main/res/drawable/ic_rating_imdb.xml
Normal file
22
app/src/main/res/drawable/ic_rating_imdb.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="575dp"
|
||||
android:height="289.83dp"
|
||||
android:viewportWidth="575"
|
||||
android:viewportHeight="289.83">
|
||||
<path
|
||||
android:pathData="M575 24.91C573.44 12.15 563.97 1.98 551.91 0C499.05 0 76.18 0 23.32 0C10.11 2.17 0 14.16 0 28.61C0 51.84 0 237.64 0 260.86C0 276.86 12.37 289.83 27.64 289.83C79.63 289.83 495.6 289.83 547.59 289.83C561.65 289.83 573.26 278.82 575 264.57C575 216.64 575 48.87 575 24.91Z"
|
||||
android:fillColor="#f6c700"/>
|
||||
<path
|
||||
android:pathData="M69.35 58.24L114.98 58.24L114.98 233.89L69.35 233.89L69.35 58.24Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M201.2 139.15C197.28 112.38 195.1 97.5 194.67 94.53C192.76 80.2 190.94 67.73 189.2 57.09C185.25 57.09 165.54 57.09 130.04 57.09L130.04 232.74L170.01 232.74L170.15 116.76L186.97 232.74L215.44 232.74L231.39 114.18L231.54 232.74L271.38 232.74L271.38 57.09L211.77 57.09L201.2 139.15Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M346.71 93.63C347.21 95.87 347.47 100.95 347.47 108.89C347.47 115.7 347.47 170.18 347.47 176.99C347.47 188.68 346.71 195.84 345.2 198.48C343.68 201.12 339.64 202.43 333.09 202.43C333.09 190.9 333.09 98.66 333.09 87.13C338.06 87.13 341.45 87.66 343.25 88.7C345.05 89.75 346.21 91.39 346.71 93.63ZM367.32 230.95C372.75 229.76 377.31 227.66 381.01 224.67C384.7 221.67 387.29 217.52 388.77 212.21C390.26 206.91 391.14 196.38 391.14 180.63C391.14 174.47 391.14 125.12 391.14 118.95C391.14 102.33 390.49 91.19 389.48 85.53C388.46 79.86 385.93 74.71 381.88 70.09C377.82 65.47 371.9 62.15 364.12 60.13C356.33 58.11 343.63 57.09 321.54 57.09C319.27 57.09 307.93 57.09 287.5 57.09L287.5 232.74L342.78 232.74C355.52 232.34 363.7 231.75 367.32 230.95Z"
|
||||
android:fillColor="#000000"/>
|
||||
<path
|
||||
android:pathData="M464.76 204.7C463.92 206.93 460.24 208.06 457.46 208.06C454.74 208.06 452.93 206.98 452.01 204.81C451.09 202.65 450.64 197.72 450.64 190C450.64 185.36 450.64 148.22 450.64 143.58C450.64 135.58 451.04 130.59 451.85 128.6C452.65 126.63 454.41 125.63 457.13 125.63C459.91 125.63 463.64 126.76 464.6 129.03C465.55 131.3 466.03 136.15 466.03 143.58C466.03 146.58 466.03 161.58 466.03 188.59C465.74 197.84 465.32 203.21 464.76 204.7ZM406.68 231.21L447.76 231.21C449.47 224.5 450.41 220.77 450.6 220.02C454.32 224.52 458.41 227.9 462.9 230.14C467.37 232.39 474.06 233.51 479.24 233.51C486.45 233.51 492.67 231.62 497.92 227.83C503.16 224.05 506.5 219.57 507.92 214.42C509.34 209.26 510.05 201.42 510.05 190.88C510.05 185.95 510.05 146.53 510.05 141.6C510.05 131 509.81 124.08 509.34 120.83C508.87 117.58 507.47 114.27 505.14 110.88C502.81 107.49 499.42 104.86 494.98 102.98C490.54 101.1 485.3 100.16 479.26 100.16C474.01 100.16 467.29 101.21 462.81 103.28C458.34 105.35 454.28 108.49 450.64 112.7C450.64 108.89 450.64 89.85 450.64 55.56L406.68 55.56L406.68 231.21Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
16
app/src/main/res/drawable/ic_rating_tmdb.xml
Normal file
16
app/src/main/res/drawable/ic_rating_tmdb.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="185.04dp"
|
||||
android:height="133.4dp"
|
||||
android:viewportWidth="185.04"
|
||||
android:viewportHeight="133.4">
|
||||
<path android:pathData="M51.06,66.7h0A17.67,17.67,0,0,1,68.73,49h-.1A17.67,17.67,0,0,1,86.3,66.7h0A17.67,17.67,0,0,1,68.63,84.37h.1A17.67,17.67,0,0,1,51.06,66.7Zm82.67-31.33h32.9A17.67,17.67,0,0,0,184.3,17.7h0A17.67,17.67,0,0,0,166.63,0h-32.9A17.67,17.67,0,0,0,116.06,17.7h0A17.67,17.67,0,0,0,133.73,35.37Zm-113,98h63.9A17.67,17.67,0,0,0,102.3,115.7h0A17.67,17.67,0,0,0,84.63,98H20.73A17.67,17.67,0,0,0,3.06,115.7h0A17.67,17.67,0,0,0,20.73,133.37Zm83.92-49h6.25L125.5,49h-8.35l-8.9,23.2h-.1L99.4,49H90.5Zm32.45,0h7.8V49h-7.8Zm22.2,0h24.95V77.2H167.1V70h15.35V62.8H167.1V56.2h16.25V49h-24ZM10.1,35.4h7.8V6.9H28V0H0V6.9H10.1ZM39,35.4h7.8V20.1H61.9V35.4h7.8V0H61.9V13.2H46.75V0H39Zm41.25,0h25V28.2H88V21h15.35V13.8H88V7.2h16.25V0h-24Zm-79,49H9V57.25h.1l9,27.15H24l9.3-27.15h.1V84.4h7.8V49H29.45l-8.2,23.1h-.1L13,49H1.2Zm112.09,49H126a24.59,24.59,0,0,0,7.56-1.15,19.52,19.52,0,0,0,6.35-3.37,16.37,16.37,0,0,0,4.37-5.5A16.91,16.91,0,0,0,146,115.8a18.5,18.5,0,0,0-1.68-8.25,15.1,15.1,0,0,0-4.52-5.53A18.55,18.55,0,0,0,133.07,99,33.54,33.54,0,0,0,125,98H113.29Zm7.81-28.2h4.6a17.43,17.43,0,0,1,4.67.62,11.68,11.68,0,0,1,3.88,1.88,9,9,0,0,1,2.62,3.18,9.87,9.87,0,0,1,1,4.52,11.92,11.92,0,0,1-1,5.08,8.69,8.69,0,0,1-2.67,3.34,10.87,10.87,0,0,1-4,1.83,21.57,21.57,0,0,1-5,.55H121.1Zm36.14,28.2h14.5a23.11,23.11,0,0,0,4.73-.5,13.38,13.38,0,0,0,4.27-1.65,9.42,9.42,0,0,0,3.1-3,8.52,8.52,0,0,0,1.2-4.68,9.16,9.16,0,0,0-.55-3.2,7.79,7.79,0,0,0-1.57-2.62,8.38,8.38,0,0,0-2.45-1.85,10,10,0,0,0-3.18-1v-.1a9.28,9.28,0,0,0,4.43-2.82,7.42,7.42,0,0,0,1.67-5,8.34,8.34,0,0,0-1.15-4.65,7.88,7.88,0,0,0-3-2.73,12.9,12.9,0,0,0-4.17-1.3,34.42,34.42,0,0,0-4.63-.32h-13.2Zm7.8-28.8h5.3a10.79,10.79,0,0,1,1.85.17,5.77,5.77,0,0,1,1.7.58,3.33,3.33,0,0,1,1.23,1.13,3.22,3.22,0,0,1,.47,1.82,3.63,3.63,0,0,1-.42,1.8,3.34,3.34,0,0,1-1.13,1.2,4.78,4.78,0,0,1-1.57.65,8.16,8.16,0,0,1-1.78.2H165Zm0,14.15h5.9a15.12,15.12,0,0,1,2.05.15,7.83,7.83,0,0,1,2,.55,4,4,0,0,1,1.58,1.17,3.13,3.13,0,0,1,.62,2,3.71,3.71,0,0,1-.47,1.95,4,4,0,0,1-1.23,1.3,4.78,4.78,0,0,1-1.67.7,8.91,8.91,0,0,1-1.83.2h-7Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient android:type="linear" android:startX="0" android:startY="66.7" android:endX="185.04" android:endY="66.7">
|
||||
<item android:offset="0" android:color="#90cea1"/>
|
||||
<item android:offset="0.56" android:color="#3cbec9"/>
|
||||
<item android:offset="1" android:color="#00b3e5"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
</vector>
|
||||
73
app/src/main/res/drawable/ic_rt_spilled.xml
Normal file
73
app/src/main/res/drawable/ic_rt_spilled.xml
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="143.75dp"
|
||||
android:height="108.75dp"
|
||||
android:viewportWidth="143.75"
|
||||
android:viewportHeight="108.75">
|
||||
<path
|
||||
android:pathData="m96.641 2.9657c28.149 1.101 27.459 97.814 0.825 97.194l-74.45-9.973c-25.51-7.211-25.922-69.313-0.534-76.178z"
|
||||
android:fillColor="#07a23b"/>
|
||||
<path
|
||||
android:pathData="m85.419 8.8789-63.171 8.9751c-4.2681 3.3648-6.9679 8.2192-7.5687 9.3296l65.017-6.963c1.3226-3.3762 4.3015-9.5395 5.7202-11.342z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m78.042 28.008-65.329 5.5498c-2.494 4.757-3.169 10.65-3.3147 15.17l66.739-1.5147c0.0074-3.2891 0.55003-13.318 1.9052-19.206z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m85.595 94.456-63.251-8.403c-4.2975-3.326-7.0398-8.1557-7.6503-9.2607l65.082 6.3737c1.3522 3.3644 4.3852 9.4999 5.8189 11.29z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m78.051 75.394-65.375-4.957c-2.536-4.734-3.2627-10.621-3.4481-15.14l66.749 0.9101c0.03629 3.2889 0.66694 13.312 2.0737 19.188z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="m100.36 10.836c-13.099 0.685-19.878 48.223-11.732 71.195l21.342-4.561c8.39-24.044 1.28-66.986-9.61-66.634z"
|
||||
android:fillColor="#03621e"/>
|
||||
<path
|
||||
android:pathData="m99.087 78.942a6.1255 6.1255 0 0 1 -6.1238 6.1255 6.1255 6.1255 0 0 1 -6.1273 -6.122 6.1255 6.1255 0 0 1 6.1202 -6.1291 6.1255 6.1255 0 0 1 6.1309 6.1184"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m112.96 75.406a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m120.39 74.743a4.9323 4.9323 0 0 1 -4.9309 4.9323 4.9323 4.9323 0 0 1 -4.9337 -4.9294 4.9323 4.9323 0 0 1 4.928 -4.9352 4.9323 4.9323 0 0 1 4.9366 4.9266"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m124.1 78.942a3.7391 3.7391 0 0 1 -3.738 3.7391 3.7391 3.7391 0 0 1 -3.7402 -3.7369 3.7391 3.7391 0 0 1 3.7358 -3.7412 3.7391 3.7391 0 0 1 3.7423 3.7347"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m131.08 83.14a5.0207 5.0207 0 0 1 -5.0192 5.0207 5.0207 5.0207 0 0 1 -5.0222 -5.0178 5.0207 5.0207 0 0 1 5.0163 -5.0236 5.0207 5.0207 0 0 1 5.0251 5.0148"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m135.86 91.67a5.5952 5.5952 0 0 1 -5.5936 5.5952 5.5952 5.5952 0 0 1 -5.5968 -5.592 5.5952 5.5952 0 0 1 5.5903 -5.5985 5.5952 5.5952 0 0 1 5.6001 5.5887"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m140.36 97.327a3.9158 3.9158 0 0 1 -3.9147 3.9158 3.9158 3.9158 0 0 1 -3.917 -3.9136 3.9158 3.9158 0 0 1 3.9124 -3.9181 3.9158 3.9158 0 0 1 3.9192 3.9113"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m140.45 99.625a4.181 4.181 0 0 1 -4.1798 4.181 4.181 4.181 0 0 1 -4.1822 -4.1786 4.181 4.181 0 0 1 4.1773 -4.1834 4.181 4.181 0 0 1 4.1846 4.1761"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m134.44 100.55a4.8439 4.8439 0 0 1 -4.8425 4.8439 4.8439 4.8439 0 0 1 -4.8453 -4.8411 4.8439 4.8439 0 0 1 4.8397 -4.8467 4.8439 4.8439 0 0 1 4.8481 4.8383"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m126.84 100.24a4.0042 4.0042 0 0 1 -4.0031 4.0042 4.0042 4.0042 0 0 1 -4.0054 -4.0019 4.0042 4.0042 0 0 1 4.0007 -4.0065 4.0042 4.0042 0 0 1 4.0077 3.9996"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m125.43 97.636a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m117.12 98.078a5.1091 5.1091 0 0 1 -5.1076 5.1091 5.1091 5.1091 0 0 1 -5.1106 -5.1061 5.1091 5.1091 0 0 1 5.1046 -5.112 5.1091 5.1091 0 0 1 5.1135 5.1031"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m110.49 97.459a3.6065 3.6065 0 0 1 -3.6054 3.6065 3.6065 3.6065 0 0 1 -3.6075 -3.6044 3.6065 3.6065 0 0 1 3.6033 -3.6086 3.6065 3.6065 0 0 1 3.6096 3.6023"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m105.72 96.929a4.0484 4.0484 0 0 1 -4.0472 4.0484 4.0484 4.0484 0 0 1 -4.0496 -4.0461 4.0484 4.0484 0 0 1 4.0449 -4.0508 4.0484 4.0484 0 0 1 4.052 4.0437"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m94.71 80.271c2.1568-1.7217 5.4319-2.8842 9.5881-3.6062l11.579 0.61872 15.203 13.612-2.0329 9.6343-27.047-2.5633-4.7288 1.4584-11.183-17.899z"
|
||||
android:fillColor="#fdd600"/>
|
||||
<path
|
||||
android:pathData="m85.913 71.627c3.2472 12.036 7.0507 22.57 12.64 28.284l-9.9879-1.591s-5.5685-25.456-4.8614-25.544c0.70711-0.08839 2.2097-1.149 2.2097-1.149z"
|
||||
android:fillColor="#09a339"/>
|
||||
</vector>
|
||||
52
app/src/main/res/drawable/ic_rt_upright.xml
Normal file
52
app/src/main/res/drawable/ic_rt_upright.xml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="106.25dp"
|
||||
android:height="140dp"
|
||||
android:viewportWidth="106.25"
|
||||
android:viewportHeight="140">
|
||||
<path
|
||||
android:pathData="M2.727 39.537c-.471-21.981 100.88-25.089 100.88-.42L92.91 117.56c-7.605 26.86-72.064 27.007-79.07.21z"
|
||||
android:fillColor="#fa3106"/>
|
||||
<path
|
||||
android:pathData="M8.809 51.911l9.018 66.639c3.472 4.515 8.498 7.384 9.648 8.022l-6.921-68.576c-3.498-1.41-9.881-4.579-11.745-6.083zM28.629 59.776l5.453 68.898c4.926 2.652 11.04 3.391 15.73 3.566l-1.258-70.366c-3.414-.024-13.82-.642-19.925-2.098zM97.632 52.121l-9.019 66.643c-3.472 4.515-8.498 7.384-9.647 8.022l6.92-68.583c3.5-1.41 9.882-4.579 11.746-6.082zM77.812 59.986l-5.453 68.898c-4.926 2.652-11.04 3.391-15.73 3.566l1.258-70.366c3.414-.024 13.82-.642 19.925-2.098z"
|
||||
android:fillColor="#fff"/>
|
||||
<path
|
||||
android:pathData="M6.397,31.252a6.816,6.816 0 1,0 13.632,0a6.816,6.816 0 1,0 -13.632,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M15.415,27.687a6.607,6.607 0 1,0 13.214,0a6.607,6.607 0 1,0 -13.214,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M24.434,19.769a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M28.943,15.155a6.03,6.03 0 1,0 12.06,0a6.03,6.03 0 1,0 -12.06,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M40.164,17.095a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M44.883,9.597a6.24,6.24 0 1,0 12.48,0a6.24,6.24 0 1,0 -12.48,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M54.636,9.387a6.554,6.554 0 1,0 13.108,0a6.554,6.554 0 1,0 -13.108,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M63.025,13.635a4.929,4.929 0 1,0 9.858,0a4.929,4.929 0 1,0 -9.858,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M70.156,17.672a5.925,5.925 0 1,0 11.85,0a5.925,5.925 0 1,0 -11.85,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M74.561,22.706a4.352,4.352 0 1,0 8.704,0a4.352,4.352 0 1,0 -8.704,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M78.232,26.324a5.243,5.243 0 1,0 10.486,0a5.243,5.243 0 1,0 -10.486,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M82.426,34.398a5.768,5.768 0 1,0 11.536,0a5.768,5.768 0 1,0 -11.536,0z"
|
||||
android:fillColor="#ffd600"/>
|
||||
<path
|
||||
android:pathData="M87.355 35.447c5.79 2.799 1.352-2.213 10.696 2.097-9.574 15.338-74.774 16.892-90.291.525l-.21-3.985L38.59 16.99l22.863-6.606 15.52 9.962z"
|
||||
android:fillColor="#ffd600"/>
|
||||
</vector>
|
||||
|
|
@ -6528,13 +6528,13 @@ paths:
|
|||
example: 85
|
||||
criticsRating:
|
||||
type: string
|
||||
enum: ['Rotten', 'Fresh', 'Certified Fresh']
|
||||
example: 'Certified Fresh'
|
||||
audienceScore:
|
||||
type: integer
|
||||
example: 65
|
||||
audienceRating:
|
||||
type: string
|
||||
enum: ['Spilled', 'Upright']
|
||||
example: 'Upright'
|
||||
/movie/{movieId}/ratingscombined:
|
||||
get:
|
||||
summary: Get RT and IMDB movie ratings combined
|
||||
|
|
@ -6573,13 +6573,13 @@ paths:
|
|||
example: 85
|
||||
criticsRating:
|
||||
type: string
|
||||
enum: ['Rotten', 'Fresh', 'Certified Fresh']
|
||||
example: 'Certified Fresh'
|
||||
audienceScore:
|
||||
type: integer
|
||||
example: 65
|
||||
audienceRating:
|
||||
type: string
|
||||
enum: ['Spilled', 'Upright']
|
||||
example: 'Upright'
|
||||
imdb:
|
||||
type: object
|
||||
properties:
|
||||
|
|
@ -6590,8 +6590,12 @@ paths:
|
|||
type: string
|
||||
example: 'https://www.imdb.com/title/tt0480249'
|
||||
criticsScore:
|
||||
type: number
|
||||
example: 8.8
|
||||
criticsScoreCount:
|
||||
type: integer
|
||||
example: 6.5
|
||||
format: int64
|
||||
example: 2500000
|
||||
/tv/{tvId}:
|
||||
get:
|
||||
summary: Get TV details
|
||||
|
|
@ -6773,7 +6777,13 @@ paths:
|
|||
example: 85
|
||||
criticsRating:
|
||||
type: string
|
||||
enum: ['Rotten', 'Fresh']
|
||||
example: 'Certified Fresh'
|
||||
audienceScore:
|
||||
type: integer
|
||||
example: 65
|
||||
audienceRating:
|
||||
type: string
|
||||
example: 'Upright'
|
||||
/person/{personId}:
|
||||
get:
|
||||
summary: Get person details
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SourceRatingsTest {
|
||||
@Test
|
||||
fun movieShowsRtCriticsRtAudienceAndImdbButNotTmdb() {
|
||||
val ratings =
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = 96,
|
||||
rtCriticsLabel = "Certified Fresh",
|
||||
rtAudienceScore = 90,
|
||||
rtAudienceLabel = "Upright",
|
||||
imdbScore = 8.8,
|
||||
imdbVotes = 2_500_000,
|
||||
tmdbScore = 8.4,
|
||||
tmdbVotes = 30_000,
|
||||
)
|
||||
assertEquals(RtScore(96, RtMark.FRESH), ratings.rtCritics)
|
||||
assertEquals(RtScore(90, RtMark.UPRIGHT), ratings.rtAudience)
|
||||
assertEquals(8.8, ratings.imdb!!, 0.0)
|
||||
assertNull(ratings.tmdb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tmdbIsShownOnlyWhenNothingElseIs() {
|
||||
val ratings = SourceRatings.of(tmdbScore = 7.633, tmdbVotes = 120)
|
||||
assertEquals(7.633, ratings.tmdb!!, 0.0)
|
||||
assertNull(ratings.rtCritics)
|
||||
assertNull(ratings.imdb)
|
||||
assertEquals(listOf("7.6"), ratings.communityEntries.map { it.text })
|
||||
|
||||
// A single non-TMDB source hides TMDB
|
||||
assertNull(SourceRatings.of(rtAudienceScore = 40, tmdbScore = 7.0, tmdbVotes = 10).tmdb)
|
||||
assertNull(SourceRatings.of(imdbScore = 5.0, imdbVotes = 10, tmdbScore = 7.0, tmdbVotes = 10).tmdb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun zeroVoteScoresAreUnratedNotZero() {
|
||||
val ratings = SourceRatings.of(imdbScore = 0.0, imdbVotes = 0, tmdbScore = 0.0, tmdbVotes = 0)
|
||||
assertTrue(ratings.isEmpty)
|
||||
|
||||
// A zero-vote IMDb score doesn't count as a source, so TMDB can still be the fallback
|
||||
val withTmdb = SourceRatings.of(imdbScore = 6.5, imdbVotes = 0, tmdbScore = 7.1, tmdbVotes = 55)
|
||||
assertNull(withTmdb.imdb)
|
||||
assertEquals(7.1, withTmdb.tmdb!!, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingVoteCountIsStillObserved() {
|
||||
assertEquals(8.8, SourceRatings.of(imdbScore = 8.8).imdb!!, 0.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outOfRangeScoresAreDropped() {
|
||||
assertTrue(SourceRatings.of(rtCriticsScore = 101, rtAudienceScore = -1).isEmpty)
|
||||
assertTrue(SourceRatings.of(imdbScore = 10.5, imdbVotes = 5, tmdbScore = -0.1, tmdbVotes = 5).isEmpty)
|
||||
assertTrue(SourceRatings.of(imdbScore = 7.0, imdbVotes = -1).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rtMarkComesFromSeerrLabelNotThePercentage() {
|
||||
// 95% but labelled Rotten and 10% labelled Fresh: the label wins
|
||||
assertEquals(RtMark.ROTTEN, SourceRatings.of(rtCriticsScore = 95, rtCriticsLabel = "Rotten").rtCritics!!.mark)
|
||||
assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 10, rtCriticsLabel = "Fresh").rtCritics!!.mark)
|
||||
assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 80, rtCriticsLabel = "Certified Fresh").rtCritics!!.mark)
|
||||
assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 20, rtAudienceLabel = "Upright").rtAudience!!.mark)
|
||||
assertEquals(RtMark.SPILLED, SourceRatings.of(rtAudienceScore = 90, rtAudienceLabel = "Spilled").rtAudience!!.mark)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rtWithoutKnownLabelUsesPwaDefaults() {
|
||||
assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90).rtCritics!!.mark)
|
||||
assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90, rtCriticsLabel = "Mystery").rtCritics!!.mark)
|
||||
assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 90).rtAudience!!.mark)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusLabelMapping() {
|
||||
assertEquals(RtMark.FRESH, RtMark.fromLabel("Certified Fresh"))
|
||||
assertEquals(RtMark.FRESH, RtMark.fromLabel("Fresh"))
|
||||
assertEquals(RtMark.ROTTEN, RtMark.fromLabel("Rotten"))
|
||||
assertEquals(RtMark.UPRIGHT, RtMark.fromLabel("Upright"))
|
||||
assertEquals(RtMark.SPILLED, RtMark.fromLabel("Spilled"))
|
||||
assertNull(RtMark.fromLabel(null))
|
||||
assertNull(RtMark.fromLabel("fresh"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entriesUseThePwaTextAndBadges() {
|
||||
val ratings =
|
||||
SourceRatings.of(
|
||||
rtCriticsScore = 85,
|
||||
rtCriticsLabel = "Fresh",
|
||||
rtAudienceScore = 40,
|
||||
rtAudienceLabel = "Spilled",
|
||||
imdbScore = 8.0,
|
||||
imdbVotes = 1,
|
||||
)
|
||||
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
|
||||
assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId })
|
||||
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
|
||||
assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId })
|
||||
}
|
||||
|
||||
private val jellyfinDetails =
|
||||
QuickDetailsData(
|
||||
basic = AnnotatedString("2020"),
|
||||
communityRating = AnnotatedString("7.8 jellyfin"),
|
||||
criticRating = AnnotatedString("59% jellyfin"),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() {
|
||||
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null))
|
||||
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() {
|
||||
val details =
|
||||
jellyfinDetails.withSourceRatings(
|
||||
SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10),
|
||||
)
|
||||
// COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes
|
||||
assertTrue(details.communityRating!!.text.endsWith("8.8"))
|
||||
assertTrue(details.criticRating!!.text.endsWith("96%"))
|
||||
assertEquals(jellyfinDetails.basic, details.basic)
|
||||
// PWA order: Rotten Tomatoes before IMDb
|
||||
assertTrue(details.criticRatingFirst)
|
||||
assertFalse(jellyfinDetails.criticRatingFirst)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() {
|
||||
// Seerr has only RT: the Jellyfin star is not shown next to it
|
||||
val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh"))
|
||||
assertNull(details.communityRating)
|
||||
assertTrue(details.criticRating!!.text.endsWith("50%"))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
package com.github.damontecres.wholphin.services
|
||||
|
||||
import com.github.damontecres.wholphin.api.seerr.MoviesApi
|
||||
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||
import com.github.damontecres.wholphin.api.seerr.TvApi
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
|
||||
import com.github.damontecres.wholphin.api.seerr.infrastructure.Serializer
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||
import com.github.damontecres.wholphin.data.model.RtMark
|
||||
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
|
||||
class SeerrRatingsServiceTest {
|
||||
private val moviesApi = mockk<MoviesApi>()
|
||||
private val tvApi = mockk<TvApi>()
|
||||
private val apiClient =
|
||||
mockk<SeerrApiClient> {
|
||||
every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi
|
||||
every { tvApi } returns this@SeerrRatingsServiceTest.tvApi
|
||||
}
|
||||
private var activeFlow = flowOf(true)
|
||||
private val seerrService =
|
||||
mockk<SeerrService> {
|
||||
every { api } returns apiClient
|
||||
every { active } answers { activeFlow }
|
||||
}
|
||||
private val service = SeerrRatingsService(seerrService)
|
||||
|
||||
private fun movieDetails(
|
||||
average: Double?,
|
||||
count: Int?,
|
||||
) = mockk<MovieDetails> {
|
||||
every { voteAverage } returns average?.toBigDecimal()
|
||||
every { voteCount } returns count
|
||||
}
|
||||
|
||||
private fun tvDetails(
|
||||
average: Double?,
|
||||
count: Int?,
|
||||
) = mockk<TvDetails> {
|
||||
every { voteAverage } returns average?.toBigDecimal()
|
||||
every { voteCount } returns count
|
||||
}
|
||||
|
||||
private val fullMovieRatings =
|
||||
MovieMovieIdRatingscombinedGet200Response(
|
||||
rt =
|
||||
MovieMovieIdRatingsGet200Response(
|
||||
criticsScore = 96,
|
||||
criticsRating = "Certified Fresh",
|
||||
audienceScore = 90,
|
||||
audienceRating = "Upright",
|
||||
),
|
||||
imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun movieUsesRatingsCombinedAndDoesNotRequestTmdbWhenOtherSourcesExist() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 10) } returns fullMovieRatings
|
||||
|
||||
val ratings = service.get(SeerrItemType.MOVIE, 10)!!
|
||||
|
||||
assertEquals(96, ratings.rtCritics?.percent)
|
||||
assertEquals(RtMark.FRESH, ratings.rtCritics?.mark)
|
||||
assertEquals(90, ratings.rtAudience?.percent)
|
||||
assertEquals(8.8, ratings.imdb!!, 0.0)
|
||||
assertNull(ratings.tmdb)
|
||||
coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unknownRtLabelsDoNotDropTheResponse() =
|
||||
runTest {
|
||||
val json = Serializer.kotlinxSerializationJson
|
||||
// Labels Seerr might add later must not fail parsing; the scores still show
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 30) } returns
|
||||
json.decodeFromString<MovieMovieIdRatingscombinedGet200Response>(
|
||||
"""{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" +
|
||||
""""imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""",
|
||||
)
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns
|
||||
json.decodeFromString<TvTvIdRatingsGet200Response>(
|
||||
"""{"criticsScore":70,"criticsRating":"Brand New","audienceScore":60,"audienceRating":"Other"}""",
|
||||
)
|
||||
|
||||
val movie = service.get(SeerrItemType.MOVIE, 30)!!
|
||||
assertEquals(92, movie.rtCritics?.percent)
|
||||
assertEquals(RtMark.UNKNOWN, movie.rtCritics?.mark)
|
||||
assertEquals(RtMark.UPRIGHT, movie.rtAudience?.mark)
|
||||
assertEquals(8.8, movie.imdb!!, 0.0)
|
||||
|
||||
val tv = service.get(SeerrItemType.TV, 31)!!
|
||||
assertEquals(70, tv.rtCritics?.percent)
|
||||
assertEquals(RtMark.UNKNOWN, tv.rtCritics?.mark)
|
||||
assertEquals(60, tv.rtAudience?.percent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() =
|
||||
runTest {
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns
|
||||
TvTvIdRatingsGet200Response(
|
||||
criticsScore = 70,
|
||||
criticsRating = "Fresh",
|
||||
audienceScore = 30,
|
||||
audienceRating = "Spilled",
|
||||
)
|
||||
|
||||
val ratings = service.get(SeerrItemType.TV, 20)!!
|
||||
|
||||
assertEquals(RtMark.FRESH, ratings.rtCritics?.mark)
|
||||
assertEquals(RtMark.SPILLED, ratings.rtAudience?.mark)
|
||||
assertNull(ratings.imdb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tmdbIsFetchedOnlyAsFallbackAndZeroVotesAreUnrated() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 1) } returns
|
||||
MovieMovieIdRatingscombinedGet200Response()
|
||||
coEvery { moviesApi.movieMovieIdGet(movieId = 1) } returns movieDetails(7.4, 321)
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 2) } returns
|
||||
MovieMovieIdRatingscombinedGet200Response()
|
||||
coEvery { moviesApi.movieMovieIdGet(movieId = 2) } returns movieDetails(0.0, 0)
|
||||
|
||||
assertEquals(7.4, service.get(SeerrItemType.MOVIE, 1)!!.tmdb!!, 0.001)
|
||||
assertTrue(service.get(SeerrItemType.MOVIE, 2)!!.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun knownTmdbVoteAvoidsExtraRequest() =
|
||||
runTest {
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = 3) } returns TvTvIdRatingsGet200Response()
|
||||
|
||||
val ratings = service.get(SeerrItemType.TV, 3, TmdbVote(8.1, 900))!!
|
||||
|
||||
assertEquals(8.1, ratings.tmdb!!, 0.0)
|
||||
coVerify(exactly = 0) { tvApi.tvTvIdGet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movieAndSeriesWithTheSameTmdbIdDoNotShareCachedRatings() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns
|
||||
TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = "Rotten")
|
||||
|
||||
val movie = service.get(SeerrItemType.MOVIE, 603)!!
|
||||
val tv = service.get(SeerrItemType.TV, 603)!!
|
||||
|
||||
assertEquals(96, movie.rtCritics?.percent)
|
||||
assertEquals(12, tv.rtCritics?.percent)
|
||||
assertEquals(RtMark.ROTTEN, tv.rtCritics?.mark)
|
||||
assertEquals(movie, service.cached(SeerrItemType.MOVIE, 603))
|
||||
assertEquals(tv, service.cached(SeerrItemType.TV, 603))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successIsCachedSoSeerrIsAskedOnce() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns fullMovieRatings
|
||||
|
||||
service.get(SeerrItemType.MOVIE, 5)
|
||||
service.get(SeerrItemType.MOVIE, 5)
|
||||
|
||||
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failuresAreNotCachedAndAreRetried() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } throws IOException("down") andThen fullMovieRatings
|
||||
|
||||
assertNull(service.get(SeerrItemType.MOVIE, 7))
|
||||
assertNull(service.cached(SeerrItemType.MOVIE, 7))
|
||||
assertNotNull(service.get(SeerrItemType.MOVIE, 7)?.rtCritics)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverErrorIsAFailureButNotFoundIsAnAnswer() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } throws ClientException("boom", 500)
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 9) } throws ClientException("none", 404)
|
||||
coEvery { moviesApi.movieMovieIdGet(movieId = 9) } returns movieDetails(6.0, 40)
|
||||
|
||||
assertNull(service.get(SeerrItemType.MOVIE, 8))
|
||||
assertNull(service.cached(SeerrItemType.MOVIE, 8))
|
||||
|
||||
// 404 from ratings means no RT/IMDb data, TMDB is still available
|
||||
assertEquals(6.0, service.get(SeerrItemType.MOVIE, 9)!!.tmdb!!, 0.0)
|
||||
assertNotNull(service.cached(SeerrItemType.MOVIE, 9))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedTmdbFallbackIsNotCachedAsNoRating() =
|
||||
runTest {
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = 11) } returns TvTvIdRatingsGet200Response()
|
||||
coEvery { tvApi.tvTvIdGet(tvId = 11) } throws IOException("down") andThen tvDetails(7.0, 10)
|
||||
|
||||
assertNull(service.get(SeerrItemType.TV, 11))
|
||||
assertEquals(7.0, service.get(SeerrItemType.TV, 11)!!.tmdb!!, 0.0)
|
||||
}
|
||||
|
||||
private fun jellyfinItem(
|
||||
type: BaseItemKind,
|
||||
providerIds: Map<String, String>?,
|
||||
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds))
|
||||
|
||||
@Test
|
||||
fun jellyfinItemResolvesRatingsThroughItsTmdbId() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings
|
||||
|
||||
val item = jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))
|
||||
assertNull(service.cachedFor(item))
|
||||
|
||||
val ratings = service.forItem(item).first()
|
||||
|
||||
assertEquals(96, ratings?.rtCritics?.percent)
|
||||
// a later screen can start from the cached value instead of the Jellyfin values
|
||||
assertEquals(ratings, service.cachedFor(item))
|
||||
assertNull(service.cachedFor(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "42"))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jellyfinItemFallsBackToNullWhenSeerrCannotAnswer() =
|
||||
runTest {
|
||||
coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns fullMovieRatings
|
||||
coEvery { tvApi.tvTvIdRatingsGet(any()) } throws IOException("down")
|
||||
|
||||
// no TMDB id
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
|
||||
// not a movie/series
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first())
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
|
||||
// call fails
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())
|
||||
// Seerr not configured
|
||||
activeFlow = flowOf(false)
|
||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first())
|
||||
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue