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