Add source ratings model and Seerr ratings service matching the PWA
This commit is contained in:
parent
d78a23a80a
commit
e92ade99df
10 changed files with 1032 additions and 40 deletions
|
|
@ -0,0 +1,223 @@
|
|||
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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
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 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)
|
||||
.build<Key, SourceRatings>()
|
||||
|
||||
fun cached(
|
||||
type: SeerrItemType,
|
||||
tmdbId: Int,
|
||||
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
|
||||
|
||||
/**
|
||||
* 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 =
|
||||
when (item.type) {
|
||||
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
|
||||
BaseItemKind.SERIES -> SeerrItemType.TV
|
||||
else -> null
|
||||
}
|
||||
val tmdbId =
|
||||
item.data.providerIds
|
||||
?.get("Tmdb")
|
||||
?.toIntOrNull()
|
||||
return if (type == null || tmdbId == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
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?.value,
|
||||
rtAudienceScore = rt.audienceScore,
|
||||
rtAudienceLabel = rt.audienceRating?.value,
|
||||
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?.value,
|
||||
rtAudienceScore = response.rt?.audienceScore,
|
||||
rtAudienceLabel = response.rt?.audienceRating?.value,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -84,15 +95,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 +122,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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
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>
|
||||
|
|
@ -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']
|
||||
enum: ['Rotten', 'Fresh', 'Certified Fresh']
|
||||
audienceScore:
|
||||
type: integer
|
||||
example: 65
|
||||
audienceRating:
|
||||
type: string
|
||||
enum: ['Spilled', 'Upright']
|
||||
/person/{personId}:
|
||||
get:
|
||||
summary: Get person details
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
package com.github.damontecres.wholphin.data.model
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import org.junit.Assert.assertEquals
|
||||
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)
|
||||
}
|
||||
|
||||
@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,232 @@
|
|||
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.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 = MovieMovieIdRatingsGet200Response.CriticsRating.CERTIFIED_FRESH,
|
||||
audienceScore = 90,
|
||||
audienceRating = MovieMovieIdRatingsGet200Response.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 seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() =
|
||||
runTest {
|
||||
coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns
|
||||
TvTvIdRatingsGet200Response(
|
||||
criticsScore = 70,
|
||||
criticsRating = TvTvIdRatingsGet200Response.CriticsRating.FRESH,
|
||||
audienceScore = 30,
|
||||
audienceRating = TvTvIdRatingsGet200Response.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 = TvTvIdRatingsGet200Response.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 ratings = service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first()
|
||||
|
||||
assertEquals(96, ratings?.rtCritics?.percent)
|
||||
}
|
||||
|
||||
@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