Add source ratings model and Seerr ratings service matching the PWA

This commit is contained in:
justinzeus 2026-10-29 20:00:31 +01:00
parent d78a23a80a
commit e92ade99df
10 changed files with 1032 additions and 40 deletions

View file

@ -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) }
},
)
}

View file

@ -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
}
}

View file

@ -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),
)
}

View 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>

View 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>

View 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>

View 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>

View file

@ -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