Merge pull request 'Show PWA source ratings on every surface through one shared rating row' (#26) from fm/wolfskin-25-ratings-everywhere into main
This commit is contained in:
commit
6bb382a126
28 changed files with 1361 additions and 567 deletions
|
|
@ -45,6 +45,7 @@ import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver
|
||||||
import com.github.damontecres.wholphin.services.RefreshRateService
|
import com.github.damontecres.wholphin.services.RefreshRateService
|
||||||
import com.github.damontecres.wholphin.services.ScreensaverService
|
import com.github.damontecres.wholphin.services.ScreensaverService
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||||
import com.github.damontecres.wholphin.services.ServerEventListener
|
import com.github.damontecres.wholphin.services.ServerEventListener
|
||||||
import com.github.damontecres.wholphin.services.SetupDestination
|
import com.github.damontecres.wholphin.services.SetupDestination
|
||||||
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
import com.github.damontecres.wholphin.services.SetupNavigationManager
|
||||||
|
|
@ -58,6 +59,7 @@ import com.github.damontecres.wholphin.ui.CoilConfig
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.collectLatestIn
|
import com.github.damontecres.wholphin.ui.collectLatestIn
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
|
import com.github.damontecres.wholphin.ui.components.LocalSeerrRatingsService
|
||||||
import com.github.damontecres.wholphin.ui.launchDefault
|
import com.github.damontecres.wholphin.ui.launchDefault
|
||||||
import com.github.damontecres.wholphin.ui.nav.Destination
|
import com.github.damontecres.wholphin.ui.nav.Destination
|
||||||
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel
|
||||||
|
|
@ -119,6 +121,9 @@ class MainActivity : AppCompatActivity() {
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var imageUrlService: ImageUrlService
|
lateinit var imageUrlService: ImageUrlService
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var seerrRatingsService: SeerrRatingsService
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
lateinit var refreshRateService: RefreshRateService
|
lateinit var refreshRateService: RefreshRateService
|
||||||
|
|
||||||
|
|
@ -263,7 +268,10 @@ class MainActivity : AppCompatActivity() {
|
||||||
LaunchedEffect(appPreferences.debugLogging) {
|
LaunchedEffect(appPreferences.debugLogging) {
|
||||||
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging
|
||||||
}
|
}
|
||||||
CompositionLocalProvider(LocalImageUrlService provides imageUrlService) {
|
CompositionLocalProvider(
|
||||||
|
LocalImageUrlService provides imageUrlService,
|
||||||
|
LocalSeerrRatingsService provides seerrRatingsService,
|
||||||
|
) {
|
||||||
WholphinTheme(
|
WholphinTheme(
|
||||||
true,
|
true,
|
||||||
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
appThemeColors = appPreferences.interfacePreferences.appThemeColors,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
|
||||||
import androidx.compose.runtime.Immutable
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
|
@ -32,7 +31,6 @@ import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.CollectionType
|
import org.jellyfin.sdk.model.api.CollectionType
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import java.util.Locale
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
@ -234,26 +232,6 @@ data class BaseItem(
|
||||||
append(it)
|
append(it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
communityRating =
|
|
||||||
data.communityRating?.let {
|
|
||||||
buildAnnotatedString {
|
|
||||||
dot()
|
|
||||||
append(String.format(Locale.getDefault(), "%.1f", it))
|
|
||||||
appendInlineContent(id = "star")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
criticRating =
|
|
||||||
data.criticRating?.let {
|
|
||||||
buildAnnotatedString {
|
|
||||||
dot()
|
|
||||||
append("${it.toInt()}%")
|
|
||||||
if (it >= 60f) {
|
|
||||||
appendInlineContent(id = "fresh")
|
|
||||||
} else {
|
|
||||||
appendInlineContent(id = "rotten")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -369,12 +347,6 @@ data class BaseItemUi(
|
||||||
data class QuickDetailsData(
|
data class QuickDetailsData(
|
||||||
val basic: AnnotatedString,
|
val basic: AnnotatedString,
|
||||||
val officialRating: AnnotatedString? = null,
|
val officialRating: AnnotatedString? = null,
|
||||||
val criticRating: AnnotatedString? = null,
|
|
||||||
val communityRating: AnnotatedString? = null,
|
|
||||||
/**
|
|
||||||
* Show [criticRating] (Rotten Tomatoes) before [communityRating] (IMDb/TMDB) like the PWA, instead of the Jellyfin order
|
|
||||||
*/
|
|
||||||
val criticRatingFirst: Boolean = false,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import androidx.compose.foundation.text.appendInlineContent
|
|
||||||
import androidx.compose.runtime.Immutable
|
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
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -43,16 +39,32 @@ data class RtScore(
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One displayable rating
|
* The badge drawn in front of a rating's number. One per PWA icon.
|
||||||
|
*/
|
||||||
|
enum class RatingBadge {
|
||||||
|
RT_FRESH,
|
||||||
|
RT_ROTTEN,
|
||||||
|
RT_UPRIGHT,
|
||||||
|
RT_SPILLED,
|
||||||
|
|
||||||
|
/** Rotten Tomatoes without a recognised label, drawn muted */
|
||||||
|
RT_UNKNOWN,
|
||||||
|
IMDB,
|
||||||
|
TMDB,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One displayable rating: a badge and its number
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
sealed interface RatingEntry {
|
sealed interface RatingEntry {
|
||||||
val text: String
|
val text: String
|
||||||
|
val badge: RatingBadge
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The id of the inline content (see [com.github.damontecres.wholphin.ui.components.rememberQuickDetailsContentMap]) for this entry's badge
|
* An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series
|
||||||
*/
|
*/
|
||||||
val inlineContentId: String
|
val isEpisode: Boolean get() = false
|
||||||
|
|
||||||
data class RottenTomatoes(
|
data class RottenTomatoes(
|
||||||
val score: RtScore,
|
val score: RtScore,
|
||||||
|
|
@ -60,14 +72,14 @@ sealed interface RatingEntry {
|
||||||
val critics: Boolean,
|
val critics: Boolean,
|
||||||
) : RatingEntry {
|
) : RatingEntry {
|
||||||
override val text: String get() = "${score.percent}%"
|
override val text: String get() = "${score.percent}%"
|
||||||
override val inlineContentId: String
|
override val badge: RatingBadge
|
||||||
get() =
|
get() =
|
||||||
when (score.mark) {
|
when (score.mark) {
|
||||||
RtMark.FRESH -> INLINE_RT_FRESH
|
RtMark.FRESH -> RatingBadge.RT_FRESH
|
||||||
RtMark.ROTTEN -> INLINE_RT_ROTTEN
|
RtMark.ROTTEN -> RatingBadge.RT_ROTTEN
|
||||||
RtMark.UPRIGHT -> INLINE_RT_UPRIGHT
|
RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT
|
||||||
RtMark.SPILLED -> INLINE_RT_SPILLED
|
RtMark.SPILLED -> RatingBadge.RT_SPILLED
|
||||||
RtMark.UNKNOWN -> INLINE_RT_UNKNOWN
|
RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,25 +87,25 @@ sealed interface RatingEntry {
|
||||||
val score: Double,
|
val score: Double,
|
||||||
) : RatingEntry {
|
) : RatingEntry {
|
||||||
override val text: String get() = formatScore(score)
|
override val text: String get() = formatScore(score)
|
||||||
override val inlineContentId: String get() = INLINE_IMDB
|
override val badge: RatingBadge get() = RatingBadge.IMDB
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Tmdb(
|
data class Tmdb(
|
||||||
val score: Double,
|
val score: Double,
|
||||||
) : RatingEntry {
|
) : RatingEntry {
|
||||||
override val text: String get() = formatScore(score)
|
override val text: String get() = formatScore(score)
|
||||||
override val inlineContentId: String get() = INLINE_TMDB
|
override val badge: RatingBadge get() = RatingBadge.TMDB
|
||||||
|
}
|
||||||
|
|
||||||
|
data class EpisodeTmdb(
|
||||||
|
val score: Double,
|
||||||
|
) : RatingEntry {
|
||||||
|
override val text: String get() = formatScore(score)
|
||||||
|
override val badge: RatingBadge get() = RatingBadge.TMDB
|
||||||
|
override val isEpisode: Boolean get() = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val INLINE_RT_FRESH = "fresh"
|
|
||||||
const val INLINE_RT_ROTTEN = "rotten"
|
|
||||||
const val INLINE_RT_UPRIGHT = "rt_upright"
|
|
||||||
const val INLINE_RT_SPILLED = "rt_spilled"
|
|
||||||
const val INLINE_RT_UNKNOWN = "rt_unknown"
|
|
||||||
const val INLINE_IMDB = "imdb"
|
|
||||||
const val INLINE_TMDB = "tmdb"
|
|
||||||
|
|
||||||
private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
|
private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -108,8 +120,10 @@ data class SourceRatings(
|
||||||
val rtAudience: RtScore? = null,
|
val rtAudience: RtScore? = null,
|
||||||
val imdb: Double? = null,
|
val imdb: Double? = null,
|
||||||
val tmdb: Double? = null,
|
val tmdb: Double? = null,
|
||||||
|
/** The TMDB user score of the episode itself, for episodes */
|
||||||
|
val episodeTmdb: Double? = null,
|
||||||
) {
|
) {
|
||||||
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null
|
val isEmpty: Boolean get() = rtCritics == null && rtAudience == null && imdb == null && tmdb == null && episodeTmdb == null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rotten Tomatoes entries in PWA order: critics then audience
|
* Rotten Tomatoes entries in PWA order: critics then audience
|
||||||
|
|
@ -129,12 +143,40 @@ data class SourceRatings(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
imdb?.let { RatingEntry.Imdb(it) },
|
imdb?.let { RatingEntry.Imdb(it) },
|
||||||
tmdb?.let { RatingEntry.Tmdb(it) },
|
tmdb?.let { RatingEntry.Tmdb(it) },
|
||||||
|
episodeTmdb?.let { RatingEntry.EpisodeTmdb(it) },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These ratings of a series with the TMDB score of one of its episodes. Rotten Tomatoes stays for context, but the
|
||||||
|
* series' own TMDB score is dropped so there is a single TMDB number, which is the episode's.
|
||||||
|
*
|
||||||
|
* @param episodeScore from [episodeScore], if null these ratings are unchanged
|
||||||
|
*/
|
||||||
|
fun withEpisode(episodeScore: Double?): SourceRatings = if (episodeScore == null) this else copy(tmdb = null, episodeTmdb = episodeScore)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The entries to draw in PWA order (Rotten Tomatoes, then IMDb or TMDB).
|
||||||
|
*
|
||||||
|
* Rotten Tomatoes is controlled by the critic rating display setting and IMDb/TMDB by the community rating setting.
|
||||||
|
*/
|
||||||
|
fun visibleEntries(
|
||||||
|
community: Boolean,
|
||||||
|
critic: Boolean,
|
||||||
|
): List<RatingEntry> = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList())
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MAX_PERCENT = 100.0
|
const val MAX_PERCENT = 100.0
|
||||||
const val MAX_SCORE = 10.0
|
const val MAX_SCORE = 10.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An episode's TMDB user score by the PWA's rules for a user score: only if observed, within 0-10, and with at least one vote
|
||||||
|
* (no votes or no vote count is unrated, never 0)
|
||||||
|
*/
|
||||||
|
fun episodeScore(
|
||||||
|
score: Number?,
|
||||||
|
votes: Long?,
|
||||||
|
): Double? = if (votes == null) null else userScore(score, votes)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds ratings using the PWA's rules:
|
* 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
|
* - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped
|
||||||
|
|
@ -187,38 +229,3 @@ data class SourceRatings(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends each entry as a dot, its badge, and its score
|
|
||||||
*/
|
|
||||||
fun AnnotatedString.Builder.appendRatings(entries: List<RatingEntry>) {
|
|
||||||
entries.forEach {
|
|
||||||
dot()
|
|
||||||
appendInlineContent(it.inlineContentId)
|
|
||||||
append(" ")
|
|
||||||
append(it.text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the Jellyfin community & critic ratings with these ratings.
|
|
||||||
*
|
|
||||||
* IMDb/TMDB are shown with the community rating setting, Rotten Tomatoes with the critic rating setting.
|
|
||||||
* If there are no ratings, the Jellyfin values are kept.
|
|
||||||
*/
|
|
||||||
fun QuickDetailsData.withSourceRatings(ratings: SourceRatings?): QuickDetailsData =
|
|
||||||
if (ratings == null || ratings.isEmpty) {
|
|
||||||
this
|
|
||||||
} else {
|
|
||||||
copy(
|
|
||||||
communityRating =
|
|
||||||
ratings.communityEntries.takeIf { it.isNotEmpty() }?.let {
|
|
||||||
buildAnnotatedString { appendRatings(it) }
|
|
||||||
},
|
|
||||||
criticRating =
|
|
||||||
ratings.criticEntries.takeIf { it.isNotEmpty() }?.let {
|
|
||||||
buildAnnotatedString { appendRatings(it) }
|
|
||||||
},
|
|
||||||
criticRatingFirst = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
package com.github.damontecres.wholphin.services
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.ui.SlimItemFields
|
import com.github.damontecres.wholphin.ui.HomeSlimItemFields
|
||||||
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
import com.github.damontecres.wholphin.util.LocalDateTimeSerializer
|
||||||
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
import com.github.damontecres.wholphin.util.WholphinDispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
|
|
@ -58,7 +58,7 @@ class LatestNextUpService
|
||||||
val request =
|
val request =
|
||||||
GetResumeItemsRequest(
|
GetResumeItemsRequest(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
fields = SlimItemFields,
|
fields = HomeSlimItemFields,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
mediaTypes = listOf(MediaType.VIDEO),
|
mediaTypes = listOf(MediaType.VIDEO),
|
||||||
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
|
excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null,
|
||||||
|
|
@ -90,7 +90,7 @@ class LatestNextUpService
|
||||||
val request =
|
val request =
|
||||||
GetNextUpRequest(
|
GetNextUpRequest(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
fields = SlimItemFields,
|
fields = HomeSlimItemFields,
|
||||||
imageTypeLimit = 1,
|
imageTypeLimit = 1,
|
||||||
parentId = null,
|
parentId = null,
|
||||||
limit = limit,
|
limit = limit,
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,19 @@ import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.google.common.cache.CacheBuilder
|
import com.google.common.cache.CacheBuilder
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.emitAll
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.mapLatest
|
import kotlinx.coroutines.flow.mapLatest
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.Optional
|
||||||
|
import java.util.UUID
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
@ -28,7 +35,24 @@ data class TmdbVote(
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses
|
* The Seerr title whose ratings are shown for a Jellyfin item
|
||||||
|
*/
|
||||||
|
data class RatingsTarget(
|
||||||
|
val type: SeerrItemType,
|
||||||
|
val tmdbId: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches [SourceRatings] for a title from Seerr, the same source the Wolfskin PWA uses. This is the only way ratings
|
||||||
|
* reach the UI, see [com.github.damontecres.wholphin.ui.components.rememberSourceRatings].
|
||||||
|
*
|
||||||
|
* Which Jellyfin items have ratings follows the PWA, which shows ratings for a title and nothing else:
|
||||||
|
* - movies show their own ratings
|
||||||
|
* - series show their own ratings
|
||||||
|
* - seasons show their series' ratings, as the PWA does for the title page a season belongs to
|
||||||
|
* - episodes show their series' Rotten Tomatoes ratings for context and their own TMDB user score, from the season
|
||||||
|
* Seerr reports (one request for all episodes of the season). The series' TMDB score is not shown next to it.
|
||||||
|
* - everything else (collections, music, photos, programs, ...) has none
|
||||||
*
|
*
|
||||||
* Movies use `ratingscombined` (RT critics, RT audience, IMDb), series use `tv/{id}/ratings` (RT). TMDB comes from
|
* 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.
|
* `voteAverage`/`voteCount` and is only requested when no other source has a score.
|
||||||
|
|
@ -41,6 +65,7 @@ class SeerrRatingsService
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
private data class Key(
|
private data class Key(
|
||||||
val type: SeerrItemType,
|
val type: SeerrItemType,
|
||||||
|
|
@ -54,43 +79,196 @@ class SeerrRatingsService
|
||||||
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
|
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
|
||||||
.build<Key, SourceRatings>()
|
.build<Key, SourceRatings>()
|
||||||
|
|
||||||
|
private data class SeasonKey(
|
||||||
|
val tmdbId: Int,
|
||||||
|
val season: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TMDB vote of each episode (by episode number) of a series' season
|
||||||
|
*/
|
||||||
|
private val seasonCache =
|
||||||
|
CacheBuilder
|
||||||
|
.newBuilder()
|
||||||
|
.maximumSize(CACHE_SIZE)
|
||||||
|
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
|
||||||
|
.build<SeasonKey, Map<Int, TmdbVote>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TMDB id of a Jellyfin movie or series, empty if it has none. Items such as an episode on the home page
|
||||||
|
* don't carry their series' provider ids, so this is how they are found.
|
||||||
|
*/
|
||||||
|
private val tmdbIds =
|
||||||
|
CacheBuilder
|
||||||
|
.newBuilder()
|
||||||
|
.maximumSize(CACHE_SIZE)
|
||||||
|
.expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS)
|
||||||
|
.build<UUID, Optional<Int>>()
|
||||||
|
|
||||||
fun cached(
|
fun cached(
|
||||||
type: SeerrItemType,
|
type: SeerrItemType,
|
||||||
tmdbId: Int,
|
tmdbId: Int,
|
||||||
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
|
): SourceRatings? = cache.getIfPresent(Key(type, tmdbId))
|
||||||
|
|
||||||
private fun target(item: BaseItem): Pair<SeerrItemType, Int>? {
|
/**
|
||||||
val type =
|
* The Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings.
|
||||||
when (item.type) {
|
* An episode's or season's own provider ids are for the episode or season, so those use the series.
|
||||||
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
|
*/
|
||||||
BaseItemKind.SERIES -> SeerrItemType.TV
|
private fun ownerId(item: BaseItem): UUID? =
|
||||||
else -> return null
|
when (item.type) {
|
||||||
}
|
BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id
|
||||||
val tmdbId =
|
BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId
|
||||||
item.data.providerIds
|
else -> null
|
||||||
?.get("Tmdb")
|
}
|
||||||
?.toIntOrNull() ?: return null
|
|
||||||
return type to tmdbId
|
/**
|
||||||
|
* The TMDB id if it is known without a request: the item's own provider ids or an earlier lookup.
|
||||||
|
* Present but empty means the item definitely has no TMDB id, null means unknown.
|
||||||
|
*/
|
||||||
|
private fun knownTmdbId(
|
||||||
|
item: BaseItem,
|
||||||
|
ownerId: UUID,
|
||||||
|
): Optional<Int>? =
|
||||||
|
item.data.providerIds
|
||||||
|
?.takeIf { ownerId == item.id && it.isNotEmpty() }
|
||||||
|
?.let { Optional.ofNullable(it["Tmdb"]?.toIntOrNull()) }
|
||||||
|
?: tmdbIds.getIfPresent(ownerId)
|
||||||
|
|
||||||
|
private fun type(item: BaseItem): SeerrItemType? =
|
||||||
|
when (item.type) {
|
||||||
|
BaseItemKind.MOVIE -> SeerrItemType.MOVIE
|
||||||
|
BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> SeerrItemType.TV
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun knownTarget(item: BaseItem): RatingsTarget? {
|
||||||
|
val type = type(item) ?: return null
|
||||||
|
val owner = ownerId(item) ?: return null
|
||||||
|
return knownTmdbId(item, owner)?.orElse(null)?.let { RatingsTarget(type, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The already cached ratings for a Jellyfin movie or series, used to seed a screen's initial state so it
|
* Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand.
|
||||||
* doesn't show the Jellyfin values first
|
* Null if the item has no ratings, no TMDB id or the lookup failed.
|
||||||
*/
|
*/
|
||||||
fun cachedFor(item: BaseItem): SourceRatings? = target(item)?.let { (type, id) -> cached(type, id) }
|
suspend fun resolveTarget(item: BaseItem): RatingsTarget? {
|
||||||
|
val type = type(item) ?: return null
|
||||||
|
val owner = ownerId(item) ?: return null
|
||||||
|
val tmdb =
|
||||||
|
knownTmdbId(item, owner) ?: try {
|
||||||
|
Optional
|
||||||
|
.ofNullable(
|
||||||
|
api.userLibraryApi
|
||||||
|
.getItem(owner)
|
||||||
|
.content.providerIds
|
||||||
|
?.get("Tmdb")
|
||||||
|
?.toIntOrNull(),
|
||||||
|
).also { tmdbIds.put(owner, it) }
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error fetching provider ids for %s", owner)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return tmdb.orElse(null)?.let { RatingsTarget(type, it) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ratings for a Jellyfin movie or series using its TMDB id. Follows whether Seerr is active, since the
|
* The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty
|
||||||
* 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?> {
|
fun cachedFor(item: BaseItem): SourceRatings? {
|
||||||
val (type, tmdbId) = target(item) ?: return flowOf(null)
|
val target = knownTarget(item) ?: return null
|
||||||
return seerrService.active
|
val series = cached(target.type, target.tmdbId) ?: return null
|
||||||
.distinctUntilChanged()
|
if (item.type != BaseItemKind.EPISODE) return series
|
||||||
.mapLatest { active -> if (active) get(type, tmdbId) else null }
|
val season = item.data.parentIndexNumber
|
||||||
|
val episode = item.data.indexNumber
|
||||||
|
if (season == null || episode == null) return series
|
||||||
|
val votes = seasonCache.getIfPresent(SeasonKey(target.tmdbId, season)) ?: return null
|
||||||
|
return series.withEpisode(episodeScore(votes[episode]))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episodeScore(vote: TmdbVote?) = vote?.let { SourceRatings.episodeScore(it.average, it.count) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ratings for a Jellyfin item. Follows whether Seerr is active, since the connection is established
|
||||||
|
* asynchronously after login.
|
||||||
|
*
|
||||||
|
* Already cached ratings are emitted at once. Otherwise nothing is requested until [debounceMillis] has passed, so
|
||||||
|
* collecting for each focused item while scrolling quickly only asks Seerr about the item the user stops on:
|
||||||
|
* collecting is cancelled for the previous item, which cancels the wait or the request in progress.
|
||||||
|
*
|
||||||
|
* Emits null while Seerr is not active, the item has no ratings (see above) or no TMDB id, or the lookup failed.
|
||||||
|
*/
|
||||||
|
fun forItem(
|
||||||
|
item: BaseItem?,
|
||||||
|
debounceMillis: Long = DEBOUNCE_MILLIS,
|
||||||
|
): Flow<SourceRatings?> {
|
||||||
|
if (item == null || type(item) == null) return flowOf(null)
|
||||||
|
cachedFor(item)?.let { return flowOf(it) }
|
||||||
|
val owner = ownerId(item) ?: return flowOf(null)
|
||||||
|
if (knownTmdbId(item, owner)?.isPresent == false) return flowOf(null)
|
||||||
|
return flow {
|
||||||
|
delay(debounceMillis)
|
||||||
|
emitAll(
|
||||||
|
seerrService.active
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.mapLatest { active ->
|
||||||
|
if (active) resolveTarget(item)?.let { getFor(item, it) } else null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ratings for the target of an item: for an episode, those of its series with its own TMDB score
|
||||||
|
*/
|
||||||
|
private suspend fun getFor(
|
||||||
|
item: BaseItem,
|
||||||
|
target: RatingsTarget,
|
||||||
|
): SourceRatings? {
|
||||||
|
val series = get(target.type, target.tmdbId)
|
||||||
|
val season = item.data.parentIndexNumber
|
||||||
|
val episode = item.data.indexNumber
|
||||||
|
if (item.type != BaseItemKind.EPISODE || season == null || episode == null) return series
|
||||||
|
val votes = getSeason(target.tmdbId, season)
|
||||||
|
if (series == null && votes == null) return null
|
||||||
|
return (series ?: SourceRatings()).withEpisode(episodeScore(votes?.get(episode)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TMDB votes of the episodes of a season, fetched once for all of them. Null if the request failed, which is not cached.
|
||||||
|
*/
|
||||||
|
private suspend fun getSeason(
|
||||||
|
tmdbId: Int,
|
||||||
|
season: Int,
|
||||||
|
): Map<Int, TmdbVote>? {
|
||||||
|
val key = SeasonKey(tmdbId, season)
|
||||||
|
seasonCache.getIfPresent(key)?.let { return it }
|
||||||
|
val votes =
|
||||||
|
try {
|
||||||
|
seerrService.api.tvApi
|
||||||
|
.tvTvIdSeasonSeasonNumberGet(tvId = tmdbId, seasonNumber = season)
|
||||||
|
.episodes
|
||||||
|
.orEmpty()
|
||||||
|
.mapNotNull { episode ->
|
||||||
|
episode.episodeNumber?.let { it to TmdbVote(episode.voteAverage?.toDouble(), episode.voteCount) }
|
||||||
|
}.toMap()
|
||||||
|
} catch (ex: CancellationException) {
|
||||||
|
throw ex
|
||||||
|
} catch (ex: ClientException) {
|
||||||
|
if (ex.statusCode == 404) {
|
||||||
|
// TMDB has no such season, that is an answer rather than a failure
|
||||||
|
emptyMap()
|
||||||
|
} else {
|
||||||
|
Timber.w(ex, "Error fetching season %s of %s", season, tmdbId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Timber.w(ex, "Error fetching season %s of %s", season, tmdbId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
seasonCache.put(key, votes)
|
||||||
|
return votes
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -228,5 +406,6 @@ class SeerrRatingsService
|
||||||
companion object {
|
companion object {
|
||||||
private const val CACHE_SIZE = 300L
|
private const val CACHE_SIZE = 300L
|
||||||
private const val CACHE_HOURS = 6L
|
private const val CACHE_HOURS = 6L
|
||||||
|
const val DEBOUNCE_MILLIS = 350L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.WholphinApplication
|
import com.github.damontecres.wholphin.WholphinApplication
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
|
||||||
import com.github.damontecres.wholphin.data.model.appendRatings
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.jellyfin.sdk.model.api.MediaSegmentType
|
import org.jellyfin.sdk.model.api.MediaSegmentType
|
||||||
|
|
@ -233,19 +231,12 @@ val MediaSegmentType.skipStringRes: Int
|
||||||
|
|
||||||
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
|
fun AnnotatedString.Builder.dot() = append(" \u2022 ")
|
||||||
|
|
||||||
fun listToDotString(
|
fun listToDotString(strings: List<String>): AnnotatedString =
|
||||||
strings: List<String>,
|
|
||||||
ratings: SourceRatings?,
|
|
||||||
): AnnotatedString =
|
|
||||||
buildAnnotatedString {
|
buildAnnotatedString {
|
||||||
strings.forEachIndexed { index, string ->
|
strings.forEachIndexed { index, string ->
|
||||||
append(string)
|
append(string)
|
||||||
if (index != strings.lastIndex) dot()
|
if (index != strings.lastIndex) dot()
|
||||||
}
|
}
|
||||||
ratings?.let {
|
|
||||||
appendRatings(it.criticEntries)
|
|
||||||
appendRatings(it.communityEntries)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:StringRes
|
@get:StringRes
|
||||||
|
|
|
||||||
|
|
@ -75,12 +75,22 @@ val SlimItemFields =
|
||||||
*/
|
*/
|
||||||
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
|
val ItemRowFields = SlimItemFields + ItemFields.PRIMARY_IMAGE_ASPECT_RATIO
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [ItemFields] for the home page's rows. [ItemFields.PROVIDER_IDS] is needed so a focused movie or series has its TMDB id
|
||||||
|
* for [com.github.damontecres.wholphin.services.SeerrRatingsService] without another request.
|
||||||
|
*/
|
||||||
val HomeItemFields =
|
val HomeItemFields =
|
||||||
listOf(
|
listOf(
|
||||||
ItemFields.OVERVIEW,
|
ItemFields.OVERVIEW,
|
||||||
ItemFields.CAN_DELETE,
|
ItemFields.CAN_DELETE,
|
||||||
|
ItemFields.PROVIDER_IDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [SlimItemFields] for the home page's continue watching & next up rows, see [HomeItemFields]
|
||||||
|
*/
|
||||||
|
val HomeSlimItemFields = SlimItemFields + ItemFields.PROVIDER_IDS
|
||||||
|
|
||||||
val PhotoItemFields =
|
val PhotoItemFields =
|
||||||
DetailItemFields +
|
DetailItemFields +
|
||||||
listOf(
|
listOf(
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ fun CollectionFolderListDetails(
|
||||||
)
|
)
|
||||||
|
|
||||||
item?.let {
|
item?.let {
|
||||||
QuickDetails(item.ui.quickDetails, null)
|
QuickDetails(item, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
OverviewText(
|
OverviewText(
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,24 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
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.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.text.InlineTextContent
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Star
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.NonRestartableComposable
|
import androidx.compose.runtime.NonRestartableComposable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.ColorFilter
|
|
||||||
import androidx.compose.ui.graphics.ColorMatrix
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.Placeholder
|
|
||||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.tv.material3.Icon
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.model.INLINE_IMDB
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
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.data.model.QuickDetailsData
|
||||||
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||||
import com.github.damontecres.wholphin.ui.dot
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
import com.github.damontecres.wholphin.ui.formatTime
|
import com.github.damontecres.wholphin.ui.formatTime
|
||||||
import com.github.damontecres.wholphin.ui.util.LocalClock
|
import com.github.damontecres.wholphin.ui.util.LocalClock
|
||||||
|
|
@ -43,6 +26,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
||||||
import org.jellyfin.sdk.model.DateTime
|
import org.jellyfin.sdk.model.DateTime
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line of details under a title: year, runtime, etc, then the ratings, then when it ends.
|
||||||
|
*
|
||||||
|
* Ratings are only drawn for [ratings], never from the Jellyfin item. Pass the result of [rememberSourceRatings] or null
|
||||||
|
* for surfaces without ratings.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun QuickDetails(
|
fun QuickDetails(
|
||||||
details: QuickDetailsData?,
|
details: QuickDetailsData?,
|
||||||
|
|
@ -50,24 +39,16 @@ fun QuickDetails(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||||
endsAt: DateTime? = null,
|
endsAt: DateTime? = null,
|
||||||
|
ratings: SourceRatings? = null,
|
||||||
) {
|
) {
|
||||||
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
|
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
|
||||||
val inlineContentMap = rememberQuickDetailsContentMap(textStyle)
|
|
||||||
Row(modifier = modifier) {
|
Row(modifier = modifier) {
|
||||||
if (details != null) {
|
if (details != null) {
|
||||||
QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap)
|
QuickDetailsText(details.basic, Modifier, textStyle)
|
||||||
if (DisplayToggle.OFFICIAL_RATING in enabled) {
|
if (DisplayToggle.OFFICIAL_RATING in enabled) {
|
||||||
QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap)
|
QuickDetailsText(details.officialRating, Modifier, textStyle)
|
||||||
}
|
|
||||||
val community = DisplayToggle.COMMUNITY_RATING in enabled
|
|
||||||
val critic = DisplayToggle.CRITIC_RATING in enabled
|
|
||||||
if (details.criticRatingFirst) {
|
|
||||||
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
|
|
||||||
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
|
|
||||||
} else {
|
|
||||||
if (community) QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap)
|
|
||||||
if (critic) QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap)
|
|
||||||
}
|
}
|
||||||
|
SourceRatingsRow(ratings, Modifier, textStyle)
|
||||||
}
|
}
|
||||||
if (timeRemaining != null) {
|
if (timeRemaining != null) {
|
||||||
TimeRemaining(timeRemaining, textStyle = textStyle)
|
TimeRemaining(timeRemaining, textStyle = textStyle)
|
||||||
|
|
@ -77,64 +58,41 @@ fun QuickDetails(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [QuickDetails] for a Jellyfin item with the ratings the PWA shows for it, if any (see [SeerrRatingsService]).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun QuickDetails(
|
||||||
|
item: BaseItem,
|
||||||
|
timeRemaining: Duration?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||||
|
endsAt: DateTime? = null,
|
||||||
|
) = QuickDetails(
|
||||||
|
details = item.ui.quickDetails,
|
||||||
|
timeRemaining = timeRemaining,
|
||||||
|
modifier = modifier,
|
||||||
|
textStyle = textStyle,
|
||||||
|
endsAt = endsAt,
|
||||||
|
ratings = rememberSourceRatings(item),
|
||||||
|
)
|
||||||
|
|
||||||
@NonRestartableComposable
|
@NonRestartableComposable
|
||||||
@Composable
|
@Composable
|
||||||
fun QuickDetailsText(
|
fun QuickDetailsText(
|
||||||
str: AnnotatedString?,
|
str: AnnotatedString?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||||
inlineContentMap: Map<String, InlineTextContent> = rememberQuickDetailsContentMap(textStyle),
|
|
||||||
) = str?.let {
|
) = str?.let {
|
||||||
Text(
|
Text(
|
||||||
text = str,
|
text = str,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
style = textStyle,
|
style = textStyle,
|
||||||
inlineContent = inlineContentMap,
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typography.titleSmall) =
|
|
||||||
remember(textStyle) {
|
|
||||||
val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
|
|
||||||
// Wordmark badges are wider than tall, like in the PWA
|
|
||||||
val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
|
|
||||||
fun badge(
|
|
||||||
placeholder: Placeholder,
|
|
||||||
@DrawableRes resId: Int,
|
|
||||||
muted: Boolean = false,
|
|
||||||
) = InlineTextContent(placeholder) {
|
|
||||||
Image(
|
|
||||||
painter = painterResource(resId),
|
|
||||||
contentDescription = null,
|
|
||||||
contentScale = ContentScale.Fit,
|
|
||||||
alpha = if (muted) 0.55f else 1f,
|
|
||||||
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
mapOf(
|
|
||||||
"star" to
|
|
||||||
InlineTextContent(square) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Star,
|
|
||||||
tint = FilledStarColor,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
INLINE_RT_ROTTEN to badge(square, R.drawable.ic_rotten_tomatoes_rotten),
|
|
||||||
INLINE_RT_FRESH to badge(square, R.drawable.ic_rotten_tomatoes_fresh),
|
|
||||||
INLINE_RT_UPRIGHT to badge(square, R.drawable.ic_rt_upright),
|
|
||||||
INLINE_RT_SPILLED to badge(square, R.drawable.ic_rt_spilled),
|
|
||||||
INLINE_RT_UNKNOWN to badge(square, R.drawable.ic_rotten_tomatoes_fresh, muted = true),
|
|
||||||
INLINE_IMDB to badge(wide, R.drawable.ic_rating_imdb),
|
|
||||||
INLINE_TMDB to badge(wide, R.drawable.ic_rating_tmdb),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TimeRemaining(
|
fun TimeRemaining(
|
||||||
remaining: Duration,
|
remaining: Duration,
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,29 @@
|
||||||
package com.github.damontecres.wholphin.ui.components
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.focusGroup
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.selection.selectable
|
|
||||||
import androidx.compose.foundation.selection.selectableGroup
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Star
|
import androidx.compose.material.icons.filled.Star
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusProperties
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.tv.material3.Icon
|
import androidx.tv.material3.Icon
|
||||||
import androidx.tv.material3.LocalContentColor
|
|
||||||
import androidx.tv.material3.LocalTextStyle
|
import androidx.tv.material3.LocalTextStyle
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
|
||||||
import com.github.damontecres.wholphin.ui.PreviewTvSpec
|
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
import com.github.damontecres.wholphin.ui.playOnClickSound
|
|
||||||
import com.github.damontecres.wholphin.ui.playSoundOnFocus
|
|
||||||
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
|
||||||
import com.github.damontecres.wholphin.ui.tryRequestFocus
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
enum class StarRatingPrecision {
|
|
||||||
FULL,
|
|
||||||
HALF,
|
|
||||||
QUARTER,
|
|
||||||
;
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromFloat(value: Float): StarRatingPrecision =
|
|
||||||
if (value <= .25f) {
|
|
||||||
QUARTER
|
|
||||||
} else if (value <= .5f) {
|
|
||||||
HALF
|
|
||||||
} else {
|
|
||||||
FULL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val FilledStarColor = Color(0xFFFFC700)
|
val FilledStarColor = Color(0xFFFFC700)
|
||||||
val EmptyStarColor = Color(0xFF5D4A0A)
|
val EmptyStarColor = Color(0xFF5D4A0A)
|
||||||
|
|
||||||
val ratingBarHeight: Dp = 32.dp
|
/**
|
||||||
|
* A minimum rating such as "7+" with a star, for the rating filter menus.
|
||||||
@Composable
|
*
|
||||||
fun SimpleStarRating(
|
* This is not for showing a title's rating, see [SourceRatingsRow] for that.
|
||||||
communityRating: Float?,
|
*/
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
|
||||||
SimpleStarRating(
|
|
||||||
text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) },
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SimpleStarRating(
|
fun SimpleStarRating(
|
||||||
text: String?,
|
text: String?,
|
||||||
|
|
@ -115,206 +51,3 @@ fun SimpleStarRating(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TomatoRating(
|
|
||||||
rating: Float?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
threshold: Float = 60f,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
if (rating != null) {
|
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
|
||||||
Text(
|
|
||||||
text = rating.toInt().toString() + "%",
|
|
||||||
maxLines = 1,
|
|
||||||
modifier = Modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val height = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() * .8f }
|
|
||||||
Icon(
|
|
||||||
painter =
|
|
||||||
painterResource(
|
|
||||||
if (rating >= threshold) {
|
|
||||||
R.drawable.ic_rotten_tomatoes_fresh
|
|
||||||
} else {
|
|
||||||
R.drawable.ic_rotten_tomatoes_rotten
|
|
||||||
},
|
|
||||||
),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.height(height),
|
|
||||||
tint = Color.Unspecified,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shows a rating out of 5 stars
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun StarRating(
|
|
||||||
rating100: Int,
|
|
||||||
onRatingChange: (Int) -> Unit,
|
|
||||||
precision: StarRatingPrecision,
|
|
||||||
enabled: Boolean,
|
|
||||||
playSoundOnFocus: Boolean,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
bgColor: Color = AppColors.TransparentBlack75, // MaterialTheme.colorScheme.background,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var tempRating by remember(rating100) { mutableIntStateOf(rating100) }
|
|
||||||
val percentage = (if (enabled) tempRating else rating100) / 100f
|
|
||||||
val focusRequesters = remember { List(5) { FocusRequester() } }
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
modifier
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(bgColor),
|
|
||||||
) {
|
|
||||||
LazyRow(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.selectableGroup()
|
|
||||||
.padding(4.dp)
|
|
||||||
.drawWithCache {
|
|
||||||
onDrawWithContent {
|
|
||||||
drawContent()
|
|
||||||
if (percentage in 0f..<1f) {
|
|
||||||
drawRect(
|
|
||||||
color = bgColor,
|
|
||||||
topLeft = Offset(size.width * percentage, 0f),
|
|
||||||
blendMode = BlendMode.SrcAtop,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.focusGroup()
|
|
||||||
.focusProperties {
|
|
||||||
onEnter = {
|
|
||||||
val index =
|
|
||||||
if (rating100 <= 20) {
|
|
||||||
0
|
|
||||||
} else if (rating100 <= 40) {
|
|
||||||
1
|
|
||||||
} else if (rating100 <= 60) {
|
|
||||||
2
|
|
||||||
} else if (rating100 <= 80) {
|
|
||||||
3
|
|
||||||
} else {
|
|
||||||
4
|
|
||||||
}
|
|
||||||
focusRequesters[index].tryRequestFocus()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
for (i in 1..5) {
|
|
||||||
item {
|
|
||||||
val isRated = (if (enabled) tempRating else rating100) >= (i * 20)
|
|
||||||
val icon = Icons.Filled.Star
|
|
||||||
var focused by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val focusedColor =
|
|
||||||
if (focused) {
|
|
||||||
MaterialTheme.colorScheme.border
|
|
||||||
} else {
|
|
||||||
Color.Unspecified
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(focusedColor),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
tint = FilledStarColor,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier =
|
|
||||||
if (enabled) {
|
|
||||||
Modifier
|
|
||||||
.onFocusChanged {
|
|
||||||
focused = it.isFocused
|
|
||||||
if (it.isFocused) {
|
|
||||||
tempRating = i * 20
|
|
||||||
} else {
|
|
||||||
tempRating = rating100
|
|
||||||
}
|
|
||||||
}.playSoundOnFocus(playSoundOnFocus)
|
|
||||||
.focusRequester(focusRequesters[i - 1])
|
|
||||||
.focusProperties {
|
|
||||||
left =
|
|
||||||
if (i == 1) focusRequesters.last() else FocusRequester.Default
|
|
||||||
right =
|
|
||||||
if (i == 5) focusRequesters.first() else FocusRequester.Default
|
|
||||||
}.selectable(
|
|
||||||
selected = isRated,
|
|
||||||
onClick = {
|
|
||||||
if (playSoundOnFocus) playOnClickSound(context)
|
|
||||||
val newRating100 =
|
|
||||||
when (precision) {
|
|
||||||
StarRatingPrecision.FULL -> {
|
|
||||||
if (i == 1 && rating100 > 0 && rating100 <= 20) 0 else i * 20
|
|
||||||
}
|
|
||||||
|
|
||||||
StarRatingPrecision.HALF -> {
|
|
||||||
if (rating100 > i * 20) {
|
|
||||||
i * 20
|
|
||||||
} else if (rating100 == i * 20) {
|
|
||||||
i * 20 - 10
|
|
||||||
} else if (rating100 == i * 20 - 10) {
|
|
||||||
(i - 1) * 20
|
|
||||||
} else if (i == 1 && rating100 == 0) {
|
|
||||||
20
|
|
||||||
} else if (i == 1 && rating100 > 10) {
|
|
||||||
10
|
|
||||||
} else if (i == 1 && rating100 <= 10) {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
(i) * 20
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StarRatingPrecision.QUARTER -> {
|
|
||||||
// TODO
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newRating100 != null) {
|
|
||||||
tempRating = newRating100
|
|
||||||
onRatingChange(newRating100)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}.fillMaxHeight()
|
|
||||||
.aspectRatio(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreviewTvSpec
|
|
||||||
@Composable
|
|
||||||
private fun SimpleStarRatingPreview() {
|
|
||||||
WholphinTheme {
|
|
||||||
Column {
|
|
||||||
SimpleStarRating(7.5f, Modifier.height(32.dp))
|
|
||||||
TomatoRating(75f)
|
|
||||||
SimpleStarRating(7.5f, Modifier.height(20.dp))
|
|
||||||
SimpleStarRating("", Modifier.height(32.dp))
|
|
||||||
TomatoRating(75f)
|
|
||||||
TomatoRating(40f)
|
|
||||||
TomatoRating(3f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.graphics.ColorMatrix
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.RatingBadge
|
||||||
|
import com.github.damontecres.wholphin.data.model.RatingEntry
|
||||||
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
|
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||||
|
import com.github.damontecres.wholphin.ui.dot
|
||||||
|
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides the [SeerrRatingsService] to [rememberSourceRatings]
|
||||||
|
*/
|
||||||
|
val LocalSeerrRatingsService =
|
||||||
|
staticCompositionLocalOf<SeerrRatingsService> { throw IllegalStateException("LocalSeerrRatingsService not set") }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ratings for an item from the same sources as the PWA. This is the only data path for ratings in the UI, every
|
||||||
|
* screen that shows ratings calls this and draws the result with [SourceRatingsRow] (usually via [QuickDetails]).
|
||||||
|
*
|
||||||
|
* Which items have ratings is decided by [SeerrRatingsService]. Returns null while loading, or if there are none, and
|
||||||
|
* never the previous item's ratings.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun rememberSourceRatings(item: BaseItem?): SourceRatings? {
|
||||||
|
val service = LocalSeerrRatingsService.current
|
||||||
|
val key = item?.id
|
||||||
|
var ratings by remember(key) { mutableStateOf(item?.let { service.cachedFor(it) }) }
|
||||||
|
LaunchedEffect(key) {
|
||||||
|
// Leaving the composition or changing the item cancels this collection, and so a Seerr request that is still waiting or running
|
||||||
|
service.forItem(item).collect { ratings = it }
|
||||||
|
}
|
||||||
|
return ratings
|
||||||
|
}
|
||||||
|
|
||||||
|
@DrawableRes
|
||||||
|
fun RatingBadge.drawable(): Int =
|
||||||
|
when (this) {
|
||||||
|
RatingBadge.RT_FRESH -> R.drawable.ic_rotten_tomatoes_fresh
|
||||||
|
RatingBadge.RT_ROTTEN -> R.drawable.ic_rotten_tomatoes_rotten
|
||||||
|
RatingBadge.RT_UPRIGHT -> R.drawable.ic_rt_upright
|
||||||
|
RatingBadge.RT_SPILLED -> R.drawable.ic_rt_spilled
|
||||||
|
RatingBadge.RT_UNKNOWN -> R.drawable.ic_rotten_tomatoes_fresh
|
||||||
|
RatingBadge.IMDB -> R.drawable.ic_rating_imdb
|
||||||
|
RatingBadge.TMDB -> R.drawable.ic_rating_tmdb
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The badges for the ratings as inline text content. IMDb and TMDB are wordmarks, wider than tall like in the PWA.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun rememberBadges(textStyle: TextStyle): Map<String, InlineTextContent> =
|
||||||
|
remember(textStyle) {
|
||||||
|
val square = Placeholder(textStyle.fontSize, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
|
||||||
|
val wide = Placeholder(textStyle.fontSize * 1.8f, textStyle.fontSize, PlaceholderVerticalAlign.TextCenter)
|
||||||
|
RatingBadge.entries.associate { badge ->
|
||||||
|
val muted = badge == RatingBadge.RT_UNKNOWN
|
||||||
|
val placeholder = if (badge == RatingBadge.IMDB || badge == RatingBadge.TMDB) wide else square
|
||||||
|
badge.name to
|
||||||
|
InlineTextContent(placeholder) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(badge.drawable()),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
alpha = if (muted) 0.55f else 1f,
|
||||||
|
colorFilter = if (muted) ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) }) else null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text for the ratings: each as a badge and its number, separated by dots
|
||||||
|
*
|
||||||
|
* @param leadingDot whether a dot is put in front of the first rating because something precedes it
|
||||||
|
* @param episodeLabel put in front of an episode's own score, "Ep" so it isn't mistaken for the series' score
|
||||||
|
*/
|
||||||
|
fun ratingsText(
|
||||||
|
entries: List<RatingEntry>,
|
||||||
|
leadingDot: Boolean,
|
||||||
|
episodeLabel: String,
|
||||||
|
): AnnotatedString =
|
||||||
|
buildAnnotatedString {
|
||||||
|
entries.forEachIndexed { index, entry ->
|
||||||
|
if (index > 0 || leadingDot) dot()
|
||||||
|
appendInlineContent(entry.badge.name)
|
||||||
|
append(" ")
|
||||||
|
if (entry.isEpisode) append("$episodeLabel ")
|
||||||
|
append(entry.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one way ratings are drawn: badge and number pairs from [SourceRatings] in the PWA's order, Rotten Tomatoes then IMDb or TMDB.
|
||||||
|
*
|
||||||
|
* Respects the community rating (IMDb/TMDB) and critic rating (Rotten Tomatoes) display settings. Draws nothing if
|
||||||
|
* there is nothing to show.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SourceRatingsRow(
|
||||||
|
ratings: SourceRatings?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
textStyle: TextStyle = MaterialTheme.typography.titleSmall,
|
||||||
|
leadingDot: Boolean = true,
|
||||||
|
) {
|
||||||
|
if (ratings == null) return
|
||||||
|
val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles
|
||||||
|
val community = DisplayToggle.COMMUNITY_RATING in enabled
|
||||||
|
val critic = DisplayToggle.CRITIC_RATING in enabled
|
||||||
|
val episodeLabel = stringResource(R.string.rating_episode_label)
|
||||||
|
val text =
|
||||||
|
remember(ratings, community, critic, leadingDot, episodeLabel) {
|
||||||
|
ratings.visibleEntries(community, critic).takeIf { it.isNotEmpty() }?.let { ratingsText(it, leadingDot, episodeLabel) }
|
||||||
|
} ?: return
|
||||||
|
val badges = rememberBadges(textStyle)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
style = textStyle,
|
||||||
|
inlineContent = badges,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
|
||||||
|
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.jellyfinId
|
import com.github.damontecres.wholphin.services.jellyfinId
|
||||||
|
|
@ -27,7 +28,7 @@ import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.formatDuration
|
import com.github.damontecres.wholphin.ui.formatDuration
|
||||||
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
|
||||||
|
|
@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader(
|
||||||
) {
|
) {
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
val details =
|
val details =
|
||||||
remember(movie, rating, resources) {
|
remember(movie, resources) {
|
||||||
buildList {
|
buildList {
|
||||||
movie.releaseDate?.let(::add)
|
movie.releaseDate?.let(::add)
|
||||||
movie.runtime
|
movie.runtime
|
||||||
|
|
@ -105,14 +106,11 @@ fun DiscoverMovieDetailsHeader(
|
||||||
?.takeIf { it.isNotNullOrBlank() }
|
?.takeIf { it.isNotNullOrBlank() }
|
||||||
?.let(::add)
|
?.let(::add)
|
||||||
}.let {
|
}.let {
|
||||||
listToDotString(
|
QuickDetailsData(listToDotString(it))
|
||||||
it,
|
|
||||||
rating,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
|
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
|
||||||
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
|
movie.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
|
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
import com.github.damontecres.wholphin.data.model.SeerrAvailability
|
||||||
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
import com.github.damontecres.wholphin.data.model.SeerrPermission
|
||||||
|
|
@ -55,7 +56,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.OverviewText
|
import com.github.damontecres.wholphin.ui.components.OverviewText
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetailsText
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
|
@ -453,7 +454,7 @@ fun DiscoverSeriesDetailsHeader(
|
||||||
) {
|
) {
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
val details =
|
val details =
|
||||||
remember(series, rating, resources) {
|
remember(series, resources) {
|
||||||
buildList {
|
buildList {
|
||||||
series.firstAirDate?.let(::add)
|
series.firstAirDate?.let(::add)
|
||||||
series.episodeRunTime
|
series.episodeRunTime
|
||||||
|
|
@ -464,14 +465,11 @@ fun DiscoverSeriesDetailsHeader(
|
||||||
?.let { add(resources.formatDuration(it)) }
|
?.let { add(resources.formatDuration(it)) }
|
||||||
// TODO
|
// TODO
|
||||||
}.let {
|
}.let {
|
||||||
listToDotString(
|
QuickDetailsData(listToDotString(it))
|
||||||
it,
|
|
||||||
rating,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding))
|
QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating)
|
||||||
series.genres?.mapNotNull { it.name }?.letNotEmpty {
|
series.genres?.mapNotNull { it.name }?.letNotEmpty {
|
||||||
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
|
GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ fun EpisodeDetailsHeader(
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
val padding = 8.dp
|
val padding = 8.dp
|
||||||
QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime)
|
QuickDetails(ep, ep.timeRemainingOrRuntime)
|
||||||
|
|
||||||
VideoStreamDetails(
|
VideoStreamDetails(
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
|
|
|
||||||
|
|
@ -339,7 +339,6 @@ fun MovieDetailsContent(
|
||||||
chosenStreams = state.chosenStreams,
|
chosenStreams = state.chosenStreams,
|
||||||
bringIntoViewRequester = bringIntoViewRequester,
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
overviewOnClick = overviewOnClick,
|
overviewOnClick = overviewOnClick,
|
||||||
sourceRatings = state.sourceRatings,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,6 @@ import androidx.tv.material3.Text
|
||||||
import com.github.damontecres.wholphin.R
|
import com.github.damontecres.wholphin.R
|
||||||
import com.github.damontecres.wholphin.data.ChosenStreams
|
import com.github.damontecres.wholphin.data.ChosenStreams
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
|
||||||
import com.github.damontecres.wholphin.data.model.withSourceRatings
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.components.GenreText
|
import com.github.damontecres.wholphin.ui.components.GenreText
|
||||||
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
import com.github.damontecres.wholphin.ui.components.HeaderUtils
|
||||||
|
|
@ -43,10 +41,8 @@ fun MovieDetailsHeader(
|
||||||
bringIntoViewRequester: BringIntoViewRequester,
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sourceRatings: SourceRatings? = null,
|
|
||||||
) {
|
) {
|
||||||
val dto = movie.data
|
val dto = movie.data
|
||||||
val quickDetails = remember(movie, sourceRatings) { movie.ui.quickDetails.withSourceRatings(sourceRatings) }
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -68,7 +64,7 @@ fun MovieDetailsHeader(
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
QuickDetails(
|
QuickDetails(
|
||||||
quickDetails,
|
movie,
|
||||||
movie.timeRemainingOrRuntime,
|
movie.timeRemainingOrRuntime,
|
||||||
Modifier.padding(start = HeaderUtils.startPadding),
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.model.Chapter
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
import com.github.damontecres.wholphin.services.ExtrasService
|
import com.github.damontecres.wholphin.services.ExtrasService
|
||||||
|
|
@ -20,7 +19,6 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
|
||||||
import com.github.damontecres.wholphin.services.SeerrService
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.services.ServerReportService
|
import com.github.damontecres.wholphin.services.ServerReportService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
|
|
@ -59,7 +57,6 @@ class MovieViewModel
|
||||||
constructor(
|
constructor(
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
private val ratingsService: SeerrRatingsService,
|
|
||||||
@param:ApplicationContext private val context: Context,
|
@param:ApplicationContext private val context: Context,
|
||||||
private val navigationManager: NavigationManager,
|
private val navigationManager: NavigationManager,
|
||||||
val serverRepository: ServerRepository,
|
val serverRepository: ServerRepository,
|
||||||
|
|
@ -103,8 +100,6 @@ class MovieViewModel
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
private var ratingsJob: Job? = null
|
|
||||||
|
|
||||||
fun init(): Job =
|
fun init(): Job =
|
||||||
viewModelScope.launchDefault {
|
viewModelScope.launchDefault {
|
||||||
val movie =
|
val movie =
|
||||||
|
|
@ -129,7 +124,6 @@ class MovieViewModel
|
||||||
chosenStreams = chosenStreams,
|
chosenStreams = chosenStreams,
|
||||||
trailers = remoteTrailers,
|
trailers = remoteTrailers,
|
||||||
chapters = chapters,
|
chapters = chapters,
|
||||||
sourceRatings = it.sourceRatings ?: ratingsService.cachedFor(movie),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
backdropService.submit(movie)
|
backdropService.submit(movie)
|
||||||
|
|
@ -158,14 +152,6 @@ class MovieViewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// init() runs again on resume and the flow never completes, so don't stack collectors
|
|
||||||
ratingsJob?.cancel()
|
|
||||||
ratingsJob =
|
|
||||||
viewModelScope.launchIO {
|
|
||||||
ratingsService.forItem(movie).collect { ratings ->
|
|
||||||
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val results = seerrService.similar(movie).orEmpty()
|
val results = seerrService.similar(movie).orEmpty()
|
||||||
_state.update {
|
_state.update {
|
||||||
|
|
@ -320,7 +306,6 @@ data class MovieState(
|
||||||
val similar: List<BaseItem> = emptyList(),
|
val similar: List<BaseItem> = emptyList(),
|
||||||
val discovered: List<DiscoverItem> = emptyList(),
|
val discovered: List<DiscoverItem> = emptyList(),
|
||||||
/** Seerr ratings, null until they load or if unavailable */
|
/** Seerr ratings, null until they load or if unavailable */
|
||||||
val sourceRatings: SourceRatings? = null,
|
|
||||||
val chosenStreams: ChosenStreams? = null,
|
val chosenStreams: ChosenStreams? = null,
|
||||||
val canDelete: Boolean = false,
|
val canDelete: Boolean = false,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,10 @@ fun FocusedEpisodeHeader(
|
||||||
) {
|
) {
|
||||||
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
|
EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding))
|
||||||
|
|
||||||
ep?.ui?.quickDetails?.let {
|
ep?.let {
|
||||||
QuickDetails(
|
QuickDetails(
|
||||||
it,
|
it,
|
||||||
ep.timeRemainingOrRuntime,
|
it.timeRemainingOrRuntime,
|
||||||
Modifier.padding(start = HeaderUtils.startPadding),
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,10 +44,8 @@ import com.github.damontecres.wholphin.data.ExtrasItem
|
||||||
import com.github.damontecres.wholphin.data.model.BaseItem
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.data.model.studioNames
|
import com.github.damontecres.wholphin.data.model.studioNames
|
||||||
import com.github.damontecres.wholphin.data.model.withSourceRatings
|
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.services.TrailerService
|
import com.github.damontecres.wholphin.services.TrailerService
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
|
|
@ -255,7 +253,6 @@ fun SeriesDetails(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
discovered = state.discovered,
|
discovered = state.discovered,
|
||||||
sourceRatings = state.sourceRatings,
|
|
||||||
onClickDiscover = { index, item ->
|
onClickDiscover = { index, item ->
|
||||||
viewModel.navigateTo(item.destination)
|
viewModel.navigateTo(item.destination)
|
||||||
},
|
},
|
||||||
|
|
@ -333,7 +330,6 @@ fun SeriesDetailsContent(
|
||||||
extras: List<ExtrasItem>,
|
extras: List<ExtrasItem>,
|
||||||
people: List<Person>,
|
people: List<Person>,
|
||||||
discovered: List<DiscoverItem>,
|
discovered: List<DiscoverItem>,
|
||||||
sourceRatings: SourceRatings?,
|
|
||||||
played: Boolean,
|
played: Boolean,
|
||||||
favorite: Boolean,
|
favorite: Boolean,
|
||||||
canDelete: Boolean,
|
canDelete: Boolean,
|
||||||
|
|
@ -381,7 +377,6 @@ fun SeriesDetailsContent(
|
||||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
overviewOnClick = overviewOnClick,
|
overviewOnClick = overviewOnClick,
|
||||||
bringIntoViewRequester = bringIntoViewRequester,
|
bringIntoViewRequester = bringIntoViewRequester,
|
||||||
sourceRatings = sourceRatings,
|
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|
@ -659,11 +654,9 @@ fun SeriesDetailsHeader(
|
||||||
overviewOnClick: () -> Unit,
|
overviewOnClick: () -> Unit,
|
||||||
bringIntoViewRequester: BringIntoViewRequester,
|
bringIntoViewRequester: BringIntoViewRequester,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sourceRatings: SourceRatings? = null,
|
|
||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val dto = series.data
|
val dto = series.data
|
||||||
val quickDetails = remember(series, sourceRatings) { series.ui.quickDetails.withSourceRatings(sourceRatings) }
|
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
|
|
@ -681,7 +674,7 @@ fun SeriesDetailsHeader(
|
||||||
modifier = Modifier.fillMaxWidth(.60f),
|
modifier = Modifier.fillMaxWidth(.60f),
|
||||||
) {
|
) {
|
||||||
QuickDetails(
|
QuickDetails(
|
||||||
quickDetails,
|
series,
|
||||||
null,
|
null,
|
||||||
Modifier.padding(start = HeaderUtils.startPadding),
|
Modifier.padding(start = HeaderUtils.startPadding),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
import com.github.damontecres.wholphin.data.model.DiscoverItem
|
||||||
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
import com.github.damontecres.wholphin.data.model.ItemPlayback
|
||||||
import com.github.damontecres.wholphin.data.model.Person
|
import com.github.damontecres.wholphin.data.model.Person
|
||||||
import com.github.damontecres.wholphin.data.model.SourceRatings
|
|
||||||
import com.github.damontecres.wholphin.data.model.Trailer
|
import com.github.damontecres.wholphin.data.model.Trailer
|
||||||
import com.github.damontecres.wholphin.preferences.AppPreferences
|
import com.github.damontecres.wholphin.preferences.AppPreferences
|
||||||
import com.github.damontecres.wholphin.services.BackdropService
|
import com.github.damontecres.wholphin.services.BackdropService
|
||||||
|
|
@ -21,7 +20,6 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
|
||||||
import com.github.damontecres.wholphin.services.MediaManagementService
|
import com.github.damontecres.wholphin.services.MediaManagementService
|
||||||
import com.github.damontecres.wholphin.services.NavigationManager
|
import com.github.damontecres.wholphin.services.NavigationManager
|
||||||
import com.github.damontecres.wholphin.services.PeopleFavorites
|
import com.github.damontecres.wholphin.services.PeopleFavorites
|
||||||
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
|
||||||
import com.github.damontecres.wholphin.services.SeerrService
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
import com.github.damontecres.wholphin.services.ServerReportService
|
import com.github.damontecres.wholphin.services.ServerReportService
|
||||||
import com.github.damontecres.wholphin.services.StreamChoiceService
|
import com.github.damontecres.wholphin.services.StreamChoiceService
|
||||||
|
|
@ -102,7 +100,6 @@ class SeriesViewModel
|
||||||
private val userPreferencesService: UserPreferencesService,
|
private val userPreferencesService: UserPreferencesService,
|
||||||
private val backdropService: BackdropService,
|
private val backdropService: BackdropService,
|
||||||
private val seerrService: SeerrService,
|
private val seerrService: SeerrService,
|
||||||
private val ratingsService: SeerrRatingsService,
|
|
||||||
private val mediaManagementService: MediaManagementService,
|
private val mediaManagementService: MediaManagementService,
|
||||||
@Assisted val seriesId: UUID,
|
@Assisted val seriesId: UUID,
|
||||||
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
@Assisted val seasonEpisodeIds: SeasonEpisodeIds?,
|
||||||
|
|
@ -221,7 +218,6 @@ class SeriesViewModel
|
||||||
seasons = seasons,
|
seasons = seasons,
|
||||||
episodes = episodes,
|
episodes = episodes,
|
||||||
trailers = remoteTrailers,
|
trailers = remoteTrailers,
|
||||||
sourceRatings = ratingsService.cachedFor(series),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,11 +251,6 @@ class SeriesViewModel
|
||||||
_state.update { it.copy(similar = similar) }
|
_state.update { it.copy(similar = similar) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launchIO {
|
|
||||||
ratingsService.forItem(series).collect { ratings ->
|
|
||||||
_state.update { it.copy(sourceRatings = ratings ?: it.sourceRatings) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModelScope.launchIO {
|
viewModelScope.launchIO {
|
||||||
val results = seerrService.similar(series).orEmpty()
|
val results = seerrService.similar(series).orEmpty()
|
||||||
_state.update { it.copy(discovered = results) }
|
_state.update { it.copy(discovered = results) }
|
||||||
|
|
@ -905,6 +896,5 @@ data class SeriesState(
|
||||||
val discovered: List<DiscoverItem> = emptyList(),
|
val discovered: List<DiscoverItem> = emptyList(),
|
||||||
val discoverSeries: DiscoverItem? = null,
|
val discoverSeries: DiscoverItem? = null,
|
||||||
/** Seerr ratings, null until they load or if unavailable */
|
/** Seerr ratings, null until they load or if unavailable */
|
||||||
val sourceRatings: SourceRatings? = null,
|
|
||||||
val chosenStreams: ChosenStreams? = null,
|
val chosenStreams: ChosenStreams? = null,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -389,7 +389,7 @@ fun SeerrDiscoverPage(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
val details =
|
val details =
|
||||||
remember(focusedItem, ratingMap) {
|
remember(focusedItem) {
|
||||||
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
|
(focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem ->
|
||||||
buildList {
|
buildList {
|
||||||
focusedItem.item
|
focusedItem.item
|
||||||
|
|
@ -398,17 +398,18 @@ fun SeerrDiscoverPage(
|
||||||
?.toString()
|
?.toString()
|
||||||
?.let(::add)
|
?.let(::add)
|
||||||
}.let {
|
}.let {
|
||||||
val rating = ratingMap[focusedItem.item.type to focusedItem.id]
|
QuickDetailsData(listToDotString(it))
|
||||||
QuickDetailsData(listToDotString(it, rating))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] }
|
||||||
HomePageHeader(
|
HomePageHeader(
|
||||||
title = focusedItem?.title?.getString(),
|
title = focusedItem?.title?.getString(),
|
||||||
subtitle = focusedItem?.subtitle,
|
subtitle = focusedItem?.subtitle,
|
||||||
overview = focusedItem?.overview,
|
overview = focusedItem?.overview,
|
||||||
overviewTwoLines = false,
|
overviewTwoLines = false,
|
||||||
quickDetails = details,
|
quickDetails = details,
|
||||||
|
ratings = focusedRatings,
|
||||||
timeRemaining = null,
|
timeRemaining = null,
|
||||||
endsAt = null,
|
endsAt = null,
|
||||||
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
showLogo = preferences.appPreferences.interfacePreferences.showLogos,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
import com.github.damontecres.wholphin.data.model.HomeRowConfig
|
||||||
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
|
||||||
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
import com.github.damontecres.wholphin.data.model.QuickDetailsData
|
||||||
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.ui.Cards
|
import com.github.damontecres.wholphin.ui.Cards
|
||||||
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
import com.github.damontecres.wholphin.ui.cards.BannerCard
|
||||||
|
|
@ -72,6 +73,7 @@ import com.github.damontecres.wholphin.ui.components.LoadingPage
|
||||||
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
import com.github.damontecres.wholphin.ui.components.QuickDetails
|
||||||
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
import com.github.damontecres.wholphin.ui.components.TitleOrLogo
|
||||||
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
|
import com.github.damontecres.wholphin.ui.components.rememberLogoUrl
|
||||||
|
import com.github.damontecres.wholphin.ui.components.rememberSourceRatings
|
||||||
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog
|
||||||
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo
|
||||||
|
|
@ -571,6 +573,7 @@ fun HomePageHeader(
|
||||||
overview = item?.data?.overview,
|
overview = item?.data?.overview,
|
||||||
overviewTwoLines = isEpisode,
|
overviewTwoLines = isEpisode,
|
||||||
quickDetails = item?.ui?.quickDetails,
|
quickDetails = item?.ui?.quickDetails,
|
||||||
|
ratings = rememberSourceRatings(item),
|
||||||
timeRemaining = item?.timeRemainingOrRuntime,
|
timeRemaining = item?.timeRemainingOrRuntime,
|
||||||
endsAt = item?.data?.endDate,
|
endsAt = item?.data?.endDate,
|
||||||
showLogo = showLogo,
|
showLogo = showLogo,
|
||||||
|
|
@ -586,6 +589,7 @@ fun HomePageHeader(
|
||||||
overview: String?,
|
overview: String?,
|
||||||
overviewTwoLines: Boolean,
|
overviewTwoLines: Boolean,
|
||||||
quickDetails: QuickDetailsData?,
|
quickDetails: QuickDetailsData?,
|
||||||
|
ratings: SourceRatings?,
|
||||||
timeRemaining: Duration?,
|
timeRemaining: Duration?,
|
||||||
endsAt: DateTime?,
|
endsAt: DateTime?,
|
||||||
showLogo: Boolean,
|
showLogo: Boolean,
|
||||||
|
|
@ -611,7 +615,7 @@ fun HomePageHeader(
|
||||||
if (subtitle != null) {
|
if (subtitle != null) {
|
||||||
EpisodeName(subtitle)
|
EpisodeName(subtitle)
|
||||||
}
|
}
|
||||||
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt)
|
QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings)
|
||||||
val overviewModifier =
|
val overviewModifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(0.dp)
|
.padding(0.dp)
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@
|
||||||
<string name="season_episode_number_padded">S%1$02dE%2$02d</string>
|
<string name="season_episode_number_padded">S%1$02dE%2$02d</string>
|
||||||
<string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string>
|
<string name="season_episode_number_padded_range">S%1$02dE%2$02d-E%3$02d</string>
|
||||||
<string name="episode_number_short">E%1$d</string>
|
<string name="episode_number_short">E%1$d</string>
|
||||||
|
<string name="rating_episode_label">Ep</string>
|
||||||
<string name="season_number">Season %1$d</string>
|
<string name="season_number">Season %1$d</string>
|
||||||
<string name="tv_shows_title">TV Shows</string>
|
<string name="tv_shows_title">TV Shows</string>
|
||||||
<string name="ui_interface">Interface</string>
|
<string name="ui_interface">Interface</string>
|
||||||
|
|
|
||||||
|
|
@ -1097,6 +1097,61 @@ components:
|
||||||
example: 0
|
example: 0
|
||||||
description: Availability of the season in 4K
|
description: Availability of the season in 4K
|
||||||
readOnly: true
|
readOnly: true
|
||||||
|
SeasonDetails:
|
||||||
|
type: object
|
||||||
|
description: The answer of the season endpoint. Everything TMDB may not know is nullable.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
airDate:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
overview:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
posterPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
seasonNumber:
|
||||||
|
type: integer
|
||||||
|
episodes:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/SeasonEpisode'
|
||||||
|
SeasonEpisode:
|
||||||
|
type: object
|
||||||
|
description: An episode of the season endpoint. voteAverage is TMDB's user score as a decimal, 0 when nobody voted.
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
overview:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
airDate:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
episodeNumber:
|
||||||
|
type: integer
|
||||||
|
seasonNumber:
|
||||||
|
type: integer
|
||||||
|
showId:
|
||||||
|
type: integer
|
||||||
|
stillPath:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
voteAverage:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
voteCount:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
nullable: true
|
||||||
TvShowStatus:
|
TvShowStatus:
|
||||||
type: string
|
type: string
|
||||||
enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"]
|
enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"]
|
||||||
|
|
@ -6651,7 +6706,7 @@ paths:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Season'
|
$ref: '#/components/schemas/SeasonDetails'
|
||||||
/tv/{tvId}/recommendations:
|
/tv/{tvId}/recommendations:
|
||||||
get:
|
get:
|
||||||
summary: Get recommended TV series
|
summary: Get recommended TV series
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package com.github.damontecres.wholphin.data.model
|
package com.github.damontecres.wholphin.data.model
|
||||||
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertSame
|
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
|
|
@ -104,44 +102,103 @@ class SourceRatingsTest {
|
||||||
imdbVotes = 1,
|
imdbVotes = 1,
|
||||||
)
|
)
|
||||||
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
|
assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text })
|
||||||
assertEquals(listOf(INLINE_RT_FRESH, INLINE_RT_SPILLED), ratings.criticEntries.map { it.inlineContentId })
|
assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge })
|
||||||
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
|
assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text })
|
||||||
assertEquals(listOf(INLINE_IMDB), ratings.communityEntries.map { it.inlineContentId })
|
assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge })
|
||||||
}
|
}
|
||||||
|
|
||||||
private val jellyfinDetails =
|
@Test
|
||||||
QuickDetailsData(
|
fun everyRtMarkHasItsOwnBadge() {
|
||||||
basic = AnnotatedString("2020"),
|
val badges =
|
||||||
communityRating = AnnotatedString("7.8 jellyfin"),
|
listOf(
|
||||||
criticRating = AnnotatedString("59% jellyfin"),
|
RtMark.FRESH to RatingBadge.RT_FRESH,
|
||||||
|
RtMark.ROTTEN to RatingBadge.RT_ROTTEN,
|
||||||
|
RtMark.UPRIGHT to RatingBadge.RT_UPRIGHT,
|
||||||
|
RtMark.SPILLED to RatingBadge.RT_SPILLED,
|
||||||
|
RtMark.UNKNOWN to RatingBadge.RT_UNKNOWN,
|
||||||
|
)
|
||||||
|
badges.forEach { (mark, badge) ->
|
||||||
|
assertEquals(badge, RatingEntry.RottenTomatoes(RtScore(50, mark), true).badge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val movie =
|
||||||
|
SourceRatings.of(
|
||||||
|
rtCriticsScore = 96,
|
||||||
|
rtCriticsLabel = "Certified Fresh",
|
||||||
|
rtAudienceScore = 59,
|
||||||
|
rtAudienceLabel = "Spilled",
|
||||||
|
imdbScore = 8.8,
|
||||||
|
imdbVotes = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun jellyfinRatingsAreKeptWhenThereAreNoSourceRatings() {
|
fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() {
|
||||||
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(null))
|
// Rotten Tomatoes (critic rating setting) before IMDb (community rating setting)
|
||||||
assertSame(jellyfinDetails, jellyfinDetails.withSourceRatings(SourceRatings()))
|
assertEquals(
|
||||||
|
listOf("96%", "59%", "8.8"),
|
||||||
|
movie.visibleEntries(community = true, critic = true).map { it.text },
|
||||||
|
)
|
||||||
|
assertEquals(listOf("8.8"), movie.visibleEntries(community = true, critic = false).map { it.text })
|
||||||
|
assertEquals(listOf("96%", "59%"), movie.visibleEntries(community = false, critic = true).map { it.text })
|
||||||
|
assertTrue(movie.visibleEntries(community = false, critic = false).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sourceRatingsReplaceJellyfinRatingsAndMapOntoTheExistingToggles() {
|
fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() {
|
||||||
val details =
|
val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true)
|
||||||
jellyfinDetails.withSourceRatings(
|
assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge })
|
||||||
SourceRatings.of(rtCriticsScore = 96, rtCriticsLabel = "Certified Fresh", imdbScore = 8.8, imdbVotes = 10),
|
assertEquals(listOf("7.6"), entries.map { it.text })
|
||||||
)
|
|
||||||
// COMMUNITY_RATING toggle -> IMDb, CRITIC_RATING toggle -> Rotten Tomatoes
|
|
||||||
assertTrue(details.communityRating!!.text.endsWith("8.8"))
|
|
||||||
assertTrue(details.criticRating!!.text.endsWith("96%"))
|
|
||||||
assertEquals(jellyfinDetails.basic, details.basic)
|
|
||||||
// PWA order: Rotten Tomatoes before IMDb
|
|
||||||
assertTrue(details.criticRatingFirst)
|
|
||||||
assertFalse(jellyfinDetails.criticRatingFirst)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun jellyfinValueForASourceThatSeerrDoesNotHaveIsNotMixedIn() {
|
fun noEntriesWithoutRatings() {
|
||||||
// Seerr has only RT: the Jellyfin star is not shown next to it
|
assertTrue(SourceRatings().isEmpty)
|
||||||
val details = jellyfinDetails.withSourceRatings(SourceRatings.of(rtCriticsScore = 50, rtCriticsLabel = "Fresh"))
|
assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty())
|
||||||
assertNull(details.communityRating)
|
assertFalse(movie.isEmpty)
|
||||||
assertTrue(details.criticRating!!.text.endsWith("50%"))
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeScoreFollowsThePwaRulesForAUserScore() {
|
||||||
|
assertEquals(7.9, SourceRatings.episodeScore(7.9, 120)!!, 0.0)
|
||||||
|
// unrated, never 0
|
||||||
|
assertNull(SourceRatings.episodeScore(0.0, 0))
|
||||||
|
assertNull(SourceRatings.episodeScore(8.0, 0))
|
||||||
|
assertNull(SourceRatings.episodeScore(8.0, null))
|
||||||
|
// not observed or out of range
|
||||||
|
assertNull(SourceRatings.episodeScore(null, 10))
|
||||||
|
assertNull(SourceRatings.episodeScore(10.5, 10))
|
||||||
|
assertNull(SourceRatings.episodeScore(-1.0, 10))
|
||||||
|
assertEquals(10.0, SourceRatings.episodeScore(10.0, 1)!!, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeKeepsTheSeriesRottenTomatoesAndHasOneLabelledTmdbNumber() {
|
||||||
|
val series =
|
||||||
|
SourceRatings.of(rtCriticsScore = 63, rtCriticsLabel = "Fresh", rtAudienceScore = 71, rtAudienceLabel = "Upright", tmdbScore = 7.6, tmdbVotes = 3000)
|
||||||
|
// the series only has its TMDB score because it has no other source
|
||||||
|
val withTmdbOnly = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 3000)
|
||||||
|
assertEquals(7.6, withTmdbOnly.tmdb!!, 0.0)
|
||||||
|
|
||||||
|
val episode = withTmdbOnly.withEpisode(8.2)
|
||||||
|
assertNull(episode.tmdb)
|
||||||
|
val entries = episode.visibleEntries(community = true, critic = true)
|
||||||
|
assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge })
|
||||||
|
assertEquals(listOf("8.2"), entries.map { it.text })
|
||||||
|
assertEquals(listOf(true), entries.map { it.isEpisode })
|
||||||
|
|
||||||
|
val full = series.withEpisode(8.2).visibleEntries(community = true, critic = true)
|
||||||
|
assertEquals(listOf("63%", "71%", "8.2"), full.map { it.text })
|
||||||
|
assertEquals(listOf(false, false, true), full.map { it.isEpisode })
|
||||||
|
// the display settings treat it as a community (IMDb/TMDB) rating
|
||||||
|
assertEquals(listOf("8.2"), series.withEpisode(8.2).visibleEntries(community = true, critic = false).map { it.text })
|
||||||
|
assertEquals(listOf("63%", "71%"), series.withEpisode(8.2).visibleEntries(community = false, critic = true).map { it.text })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withoutAnEpisodeScoreTheSeriesRatingsAreUnchanged() {
|
||||||
|
val series = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 10)
|
||||||
|
assertEquals(series, series.withEpisode(null))
|
||||||
|
assertFalse(SourceRatings().withEpisode(5.0).isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,426 @@
|
||||||
|
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.MoviesApi
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.TvApi
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.SeerrItemType
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which Jellyfin items have ratings, how their TMDB id is found, and when Seerr is asked
|
||||||
|
*/
|
||||||
|
class SeerrRatingsItemsTest {
|
||||||
|
private val moviesApi = mockk<MoviesApi>()
|
||||||
|
private val tvApi = mockk<TvApi>()
|
||||||
|
private val seerrApi =
|
||||||
|
mockk<SeerrApiClient> {
|
||||||
|
every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi
|
||||||
|
every { tvApi } returns this@SeerrRatingsItemsTest.tvApi
|
||||||
|
}
|
||||||
|
private val active = MutableStateFlow(true)
|
||||||
|
private val seerrService =
|
||||||
|
mockk<SeerrService> {
|
||||||
|
every { api } returns seerrApi
|
||||||
|
every { this@mockk.active } returns this@SeerrRatingsItemsTest.active
|
||||||
|
}
|
||||||
|
private val userLibraryApi = mockk<UserLibraryApi>()
|
||||||
|
private val jellyfin =
|
||||||
|
mockk<ApiClient> {
|
||||||
|
every { userLibraryApi } returns this@SeerrRatingsItemsTest.userLibraryApi
|
||||||
|
}
|
||||||
|
private val service = SeerrRatingsService(seerrService, jellyfin)
|
||||||
|
|
||||||
|
private val seriesId = UUID.randomUUID()
|
||||||
|
private val seriesTmdb = 1396
|
||||||
|
|
||||||
|
private val rtOnly = MovieMovieIdRatingscombinedGet200Response(rt = MovieMovieIdRatingsGet200Response(criticsScore = 90, criticsRating = "Fresh"))
|
||||||
|
private val tvRatings = TvTvIdRatingsGet200Response(criticsScore = 80, criticsRating = "Fresh")
|
||||||
|
|
||||||
|
private fun item(
|
||||||
|
type: BaseItemKind,
|
||||||
|
providerIds: Map<String, String>? = null,
|
||||||
|
seriesId: UUID? = null,
|
||||||
|
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds, seriesId = seriesId))
|
||||||
|
|
||||||
|
private fun jellyfinHasSeries(tmdb: Int? = seriesTmdb) {
|
||||||
|
coEvery { userLibraryApi.getItem(seriesId) } returns
|
||||||
|
successResponse(
|
||||||
|
BaseItemDto(
|
||||||
|
id = seriesId,
|
||||||
|
type = BaseItemKind.SERIES,
|
||||||
|
providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) } ?: mapOf("Tvdb" to "1"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moviesAndSeriesUseTheirOwnProviderIdsWithoutAskingJellyfin() =
|
||||||
|
runTest {
|
||||||
|
assertEquals(
|
||||||
|
RatingsTarget(SeerrItemType.MOVIE, 11),
|
||||||
|
service.resolveTarget(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "11"))),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
RatingsTarget(SeerrItemType.TV, 22),
|
||||||
|
service.resolveTarget(item(BaseItemKind.SERIES, mapOf("Tmdb" to "22"))),
|
||||||
|
)
|
||||||
|
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodesAndSeasonsShowTheirSeriesRatingsNotTheirOwnTmdbId() =
|
||||||
|
runTest {
|
||||||
|
jellyfinHasSeries()
|
||||||
|
// The Tmdb id on an episode or season identifies the episode or season, never the series
|
||||||
|
val episode = item(BaseItemKind.EPISODE, mapOf("Tmdb" to "999999"), seriesId)
|
||||||
|
val season = item(BaseItemKind.SEASON, mapOf("Tmdb" to "888888"), seriesId)
|
||||||
|
|
||||||
|
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode))
|
||||||
|
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(season))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun itemsThatArePartOfNoPwaTitleHaveNoRatingsAndNeverCallAnything() =
|
||||||
|
runTest {
|
||||||
|
val none =
|
||||||
|
listOf(
|
||||||
|
BaseItemKind.BOX_SET,
|
||||||
|
BaseItemKind.MUSIC_ALBUM,
|
||||||
|
BaseItemKind.MUSIC_ARTIST,
|
||||||
|
BaseItemKind.AUDIO,
|
||||||
|
BaseItemKind.PHOTO,
|
||||||
|
BaseItemKind.PHOTO_ALBUM,
|
||||||
|
BaseItemKind.PROGRAM,
|
||||||
|
BaseItemKind.PLAYLIST,
|
||||||
|
BaseItemKind.VIDEO,
|
||||||
|
)
|
||||||
|
none.forEach {
|
||||||
|
val item = item(it, mapOf("Tmdb" to "5"), seriesId)
|
||||||
|
assertNull(it.name, service.resolveTarget(item))
|
||||||
|
assertNull(it.name, service.forItem(item).first())
|
||||||
|
assertNull(it.name, service.cachedFor(item))
|
||||||
|
}
|
||||||
|
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
|
||||||
|
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||||
|
coVerify(exactly = 0) { tvApi.tvTvIdRatingsGet(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeWithoutASeriesHasNoRatings() =
|
||||||
|
runTest {
|
||||||
|
assertNull(service.resolveTarget(item(BaseItemKind.EPISODE, mapOf("Tmdb" to "3"), null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun homeItemsWithoutProviderIdsAreResolvedThroughJellyfinOnceAndCached() =
|
||||||
|
runTest {
|
||||||
|
jellyfinHasSeries()
|
||||||
|
val first = item(BaseItemKind.EPISODE, null, seriesId)
|
||||||
|
val second = item(BaseItemKind.EPISODE, null, seriesId)
|
||||||
|
|
||||||
|
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(first))
|
||||||
|
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(second))
|
||||||
|
|
||||||
|
// every other episode of the series is known without a request
|
||||||
|
coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun homeMovieWithoutProviderIdsLooksItUp() =
|
||||||
|
runTest {
|
||||||
|
val movie = item(BaseItemKind.MOVIE, null)
|
||||||
|
coEvery { userLibraryApi.getItem(movie.id) } returns
|
||||||
|
successResponse(BaseItemDto(id = movie.id, type = BaseItemKind.MOVIE, providerIds = mapOf("Tmdb" to "77")))
|
||||||
|
|
||||||
|
assertEquals(RatingsTarget(SeerrItemType.MOVIE, 77), service.resolveTarget(movie))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seriesWithoutTmdbIdHasNoRatingsAndIsNotLookedUpAgain() =
|
||||||
|
runTest {
|
||||||
|
jellyfinHasSeries(tmdb = null)
|
||||||
|
val episode = item(BaseItemKind.EPISODE, null, seriesId)
|
||||||
|
|
||||||
|
assertNull(service.resolveTarget(episode))
|
||||||
|
assertNull(service.forItem(episode).first())
|
||||||
|
coVerify(exactly = 1) { userLibraryApi.getItem(seriesId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedLookupIsRetriedLater() =
|
||||||
|
runTest {
|
||||||
|
coEvery { userLibraryApi.getItem(seriesId) } throws IOException("down")
|
||||||
|
val episode = item(BaseItemKind.EPISODE, null, seriesId)
|
||||||
|
assertNull(service.resolveTarget(episode))
|
||||||
|
|
||||||
|
jellyfinHasSeries()
|
||||||
|
assertEquals(RatingsTarget(SeerrItemType.TV, seriesTmdb), service.resolveTarget(episode))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeRatingsAreTheSeriesRatingsAndSeedLaterEpisodes() =
|
||||||
|
runTest {
|
||||||
|
jellyfinHasSeries()
|
||||||
|
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings
|
||||||
|
val episode = item(BaseItemKind.EPISODE, null, seriesId)
|
||||||
|
val other = item(BaseItemKind.EPISODE, null, seriesId)
|
||||||
|
assertNull(service.cachedFor(episode))
|
||||||
|
|
||||||
|
val ratings = service.forItem(episode).first()
|
||||||
|
|
||||||
|
assertEquals(80, ratings?.rtCritics?.percent)
|
||||||
|
// moving to another episode of the same series needs no wait and no request
|
||||||
|
assertEquals(ratings, service.cachedFor(other))
|
||||||
|
assertEquals(ratings, service.forItem(other).first())
|
||||||
|
coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollingPastItemsNeverAsksSeerr() =
|
||||||
|
runTest {
|
||||||
|
coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns rtOnly
|
||||||
|
val jobs =
|
||||||
|
(1..20).map { id ->
|
||||||
|
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "$id"))).collect { } }
|
||||||
|
runCurrent()
|
||||||
|
// focus moves on before the wait has passed
|
||||||
|
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50)
|
||||||
|
job.cancel()
|
||||||
|
job
|
||||||
|
}
|
||||||
|
jobs.forEach(Job::cancel)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun restingOnAnItemAsksSeerrOnceAfterTheWait() =
|
||||||
|
runTest {
|
||||||
|
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } returns rtOnly
|
||||||
|
var result: Any? = "unset"
|
||||||
|
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "7"))).collect { result = it } }
|
||||||
|
|
||||||
|
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 1)
|
||||||
|
runCurrent()
|
||||||
|
assertEquals("unset", result)
|
||||||
|
coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) }
|
||||||
|
|
||||||
|
advanceTimeBy(2)
|
||||||
|
runCurrent()
|
||||||
|
assertNotNull(result)
|
||||||
|
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) }
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movingOnWhileTheRequestIsRunningCancelsIt() =
|
||||||
|
runTest {
|
||||||
|
var cancelled = false
|
||||||
|
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } coAnswers {
|
||||||
|
try {
|
||||||
|
awaitCancellation()
|
||||||
|
} finally {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val job = launch { service.forItem(item(BaseItemKind.MOVIE, mapOf("Tmdb" to "8"))).collect { } }
|
||||||
|
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS + 1)
|
||||||
|
runCurrent()
|
||||||
|
coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) }
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
assertEquals(true, cancelled)
|
||||||
|
assertNull(service.cached(SeerrItemType.MOVIE, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun season(vararg episodes: Triple<Int, Double?, Long?>) =
|
||||||
|
SeasonDetails(
|
||||||
|
seasonNumber = 5,
|
||||||
|
episodes = episodes.map { (number, average, votes) -> SeasonEpisode(episodeNumber = number, voteAverage = average?.toBigDecimal(), voteCount = votes) },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun episode(
|
||||||
|
season: Int?,
|
||||||
|
episode: Int?,
|
||||||
|
) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.EPISODE, seriesId = seriesId, parentIndexNumber = season, indexNumber = episode))
|
||||||
|
|
||||||
|
private fun seriesKnown() {
|
||||||
|
jellyfinHasSeries()
|
||||||
|
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns tvRatings
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theSeasonIsFetchedOnceForEveryEpisodeOfIt() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns
|
||||||
|
season(Triple(1, 8.24, 120L), Triple(2, 7.0, 80L), Triple(3, 9.5, 10L))
|
||||||
|
|
||||||
|
val first = service.forItem(episode(5, 1)).first()!!
|
||||||
|
val second = service.forItem(episode(5, 2)).first()!!
|
||||||
|
val third = service.forItem(episode(5, 3)).first()!!
|
||||||
|
|
||||||
|
// each episode has its own score next to the series' Rotten Tomatoes
|
||||||
|
assertEquals(listOf(8.24, 7.0, 9.5), listOf(first, second, third).map { it.episodeTmdb })
|
||||||
|
assertEquals(80, first.rtCritics?.percent)
|
||||||
|
coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) }
|
||||||
|
coVerify(exactly = 1) { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) }
|
||||||
|
// and another episode of the season is known without waiting
|
||||||
|
assertEquals(7.0, service.cachedFor(episode(5, 2))!!.episodeTmdb!!, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun eachSeasonIsItsOwnRequest() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 1) } returns season(Triple(1, 6.0, 5L))
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 2) } returns season(Triple(1, 9.0, 5L))
|
||||||
|
|
||||||
|
assertEquals(6.0, service.forItem(episode(1, 1)).first()!!.episodeTmdb!!, 0.0)
|
||||||
|
assertEquals(9.0, service.forItem(episode(2, 1)).first()!!.episodeTmdb!!, 0.0)
|
||||||
|
// the same episode number of another season is not mistaken for it
|
||||||
|
assertNull(service.cachedFor(episode(3, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unratedEpisodesShowOnlyTheSeriesRatings() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns
|
||||||
|
season(Triple(1, 0.0, 0L), Triple(2, 8.0, null), Triple(3, null, null))
|
||||||
|
|
||||||
|
listOf(1, 2, 3, 4).forEach { number ->
|
||||||
|
val ratings = service.forItem(episode(5, number)).first()!!
|
||||||
|
assertNull("episode $number", ratings.episodeTmdb)
|
||||||
|
assertEquals("episode $number", 80, ratings.rtCritics?.percent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeWithoutSeasonOrEpisodeNumberShowsTheSeriesRatingsWithoutAskingForTheSeason() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
|
||||||
|
assertEquals(80, service.forItem(episode(null, 1)).first()!!.rtCritics?.percent)
|
||||||
|
assertEquals(80, service.forItem(episode(5, null)).first()!!.rtCritics?.percent)
|
||||||
|
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeTmdbReplacesTheSeriesTmdbWhenThatWasTheOnlySource() =
|
||||||
|
runTest {
|
||||||
|
jellyfinHasSeries()
|
||||||
|
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns TvTvIdRatingsGet200Response()
|
||||||
|
coEvery { tvApi.tvTvIdGet(tvId = seriesTmdb) } returns
|
||||||
|
mockk {
|
||||||
|
every { voteAverage } returns 7.6.toBigDecimal()
|
||||||
|
every { voteCount } returns 900
|
||||||
|
}
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns season(Triple(1, 8.8, 40L))
|
||||||
|
|
||||||
|
val ratings = service.forItem(episode(5, 1)).first()!!
|
||||||
|
|
||||||
|
assertNull(ratings.tmdb)
|
||||||
|
assertEquals(8.8, ratings.episodeTmdb!!, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedSeasonRequestKeepsTheSeriesRatingsAndIsRetried() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } throws IOException("down") andThen
|
||||||
|
season(Triple(1, 8.0, 3L))
|
||||||
|
|
||||||
|
val first = service.forItem(episode(5, 1)).first()!!
|
||||||
|
assertNull(first.episodeTmdb)
|
||||||
|
assertEquals(80, first.rtCritics?.percent)
|
||||||
|
// not cached as "no score": the screen has to wait again
|
||||||
|
assertNull(service.cachedFor(episode(5, 1)))
|
||||||
|
|
||||||
|
assertEquals(8.0, service.forItem(episode(5, 1)).first()!!.episodeTmdb!!, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seasonTmdbDoesNotKnowIsAnAnswer() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) } throws ClientException("none", 404)
|
||||||
|
|
||||||
|
assertNull(service.forItem(episode(9, 1)).first()!!.episodeTmdb)
|
||||||
|
service.forItem(episode(9, 2)).first()
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 9) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seriesWithoutATmdbIdShowsNothingExtra() =
|
||||||
|
runTest {
|
||||||
|
jellyfinHasSeries(tmdb = null)
|
||||||
|
|
||||||
|
assertNull(service.forItem(episode(5, 1)).first())
|
||||||
|
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seasonsStillShowOnlyTheirSeriesRatings() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
val season = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = BaseItemKind.SEASON, seriesId = seriesId, indexNumber = 5, parentIndexNumber = 5))
|
||||||
|
|
||||||
|
assertNull(service.forItem(season).first()!!.episodeTmdb)
|
||||||
|
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollingPastEpisodesNeverFetchesASeason() =
|
||||||
|
runTest {
|
||||||
|
seriesKnown()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) } returns season(Triple(1, 8.0, 3L))
|
||||||
|
repeat(10) { number ->
|
||||||
|
val job = launch { service.forItem(episode(5, number + 1)).collect { } }
|
||||||
|
runCurrent()
|
||||||
|
advanceTimeBy(SeerrRatingsService.DEBOUNCE_MILLIS - 50)
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { tvApi.tvTvIdSeasonSeasonNumberGet(any(), any(), any()) }
|
||||||
|
coVerify(exactly = 0) { userLibraryApi.getItem(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@ import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
|
@ -45,7 +46,7 @@ class SeerrRatingsServiceTest {
|
||||||
every { api } returns apiClient
|
every { api } returns apiClient
|
||||||
every { active } answers { activeFlow }
|
every { active } answers { activeFlow }
|
||||||
}
|
}
|
||||||
private val service = SeerrRatingsService(seerrService)
|
private val service = SeerrRatingsService(seerrService, mockk<ApiClient>())
|
||||||
|
|
||||||
private fun movieDetails(
|
private fun movieDetails(
|
||||||
average: Double?,
|
average: Double?,
|
||||||
|
|
@ -253,8 +254,7 @@ class SeerrRatingsServiceTest {
|
||||||
// no TMDB id
|
// no TMDB id
|
||||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
|
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first())
|
||||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
|
assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first())
|
||||||
// not a movie/series
|
// not something that has ratings
|
||||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.EPISODE, mapOf("Tmdb" to "1"))).first())
|
|
||||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
|
assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first())
|
||||||
// call fails
|
// call fails
|
||||||
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())
|
assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first())
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,281 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.MoviesApi
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.TvApi
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.SeasonDetails
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.SeasonEpisode
|
||||||
|
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
|
||||||
|
import com.github.damontecres.wholphin.data.model.BaseItem
|
||||||
|
import com.github.damontecres.wholphin.data.model.SourceRatings
|
||||||
|
import com.github.damontecres.wholphin.preferences.DisplayToggle
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrRatingsService
|
||||||
|
import com.github.damontecres.wholphin.services.SeerrService
|
||||||
|
import com.github.damontecres.wholphin.ui.successResponse
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import com.github.damontecres.wholphin.ui.util.InterfaceCustomization
|
||||||
|
import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.jellyfin.sdk.api.client.ApiClient
|
||||||
|
import org.jellyfin.sdk.api.client.extensions.userLibraryApi
|
||||||
|
import org.jellyfin.sdk.api.operations.UserLibraryApi
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What every surface shows: the rating row comes only from Seerr, per the PWA's rules for the kind of item, and
|
||||||
|
* the Jellyfin rating of an item is never drawn
|
||||||
|
*/
|
||||||
|
@Config(sdk = [34])
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class SourceRatingsRowTest {
|
||||||
|
@get:Rule val rule = createComposeRule()
|
||||||
|
|
||||||
|
private val moviesApi = mockk<MoviesApi>()
|
||||||
|
private val tvApi = mockk<TvApi>()
|
||||||
|
private val seerrApi =
|
||||||
|
mockk<SeerrApiClient> {
|
||||||
|
every { moviesApi } returns this@SourceRatingsRowTest.moviesApi
|
||||||
|
every { tvApi } returns this@SourceRatingsRowTest.tvApi
|
||||||
|
}
|
||||||
|
private val seerrService =
|
||||||
|
mockk<SeerrService> {
|
||||||
|
every { api } returns seerrApi
|
||||||
|
every { active } returns flowOf(true)
|
||||||
|
}
|
||||||
|
private val userLibraryApi = mockk<UserLibraryApi>()
|
||||||
|
private val jellyfin = mockk<ApiClient> { every { userLibraryApi } returns this@SourceRatingsRowTest.userLibraryApi }
|
||||||
|
private val service = SeerrRatingsService(seerrService, jellyfin)
|
||||||
|
|
||||||
|
private val seriesId = UUID.randomUUID()
|
||||||
|
private val seriesTmdb = 1396
|
||||||
|
|
||||||
|
/** Every item carries Jellyfin ratings that must never be drawn */
|
||||||
|
private fun item(
|
||||||
|
type: BaseItemKind,
|
||||||
|
tmdb: Int? = null,
|
||||||
|
seriesId: UUID? = null,
|
||||||
|
) = BaseItem(
|
||||||
|
BaseItemDto(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
type = type,
|
||||||
|
name = "Title",
|
||||||
|
providerIds = tmdb?.let { mapOf("Tmdb" to it.toString()) },
|
||||||
|
seriesId = seriesId,
|
||||||
|
communityRating = 7.8f,
|
||||||
|
criticRating = 59f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun movieRatings() {
|
||||||
|
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns
|
||||||
|
MovieMovieIdRatingscombinedGet200Response(
|
||||||
|
rt = MovieMovieIdRatingsGet200Response(criticsScore = 96, criticsRating = "Certified Fresh", audienceScore = 90, audienceRating = "Upright"),
|
||||||
|
imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 10),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seriesRatings() {
|
||||||
|
coEvery { tvApi.tvTvIdRatingsGet(tvId = seriesTmdb) } returns
|
||||||
|
TvTvIdRatingsGet200Response(criticsScore = 81, criticsRating = "Fresh", audienceScore = 45, audienceRating = "Spilled")
|
||||||
|
coEvery { userLibraryApi.getItem(seriesId) } returns
|
||||||
|
successResponse(BaseItemDto(id = seriesId, type = BaseItemKind.SERIES, providerIds = mapOf("Tmdb" to "$seriesTmdb")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the quick details of the items, after what a screen's wait for Seerr would have loaded
|
||||||
|
*/
|
||||||
|
private fun show(
|
||||||
|
vararg items: BaseItem,
|
||||||
|
toggles: EnumSet<DisplayToggle> = EnumSet.allOf(DisplayToggle::class.java),
|
||||||
|
) {
|
||||||
|
runBlocking {
|
||||||
|
items.forEach { service.forItem(it, 0).collect { } }
|
||||||
|
}
|
||||||
|
rule.setContent {
|
||||||
|
WholphinTheme {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSeerrRatingsService provides service,
|
||||||
|
LocalInterfaceCustomization provides InterfaceCustomization(toggles),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
items.forEach { QuickDetails(it, null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertShown(text: String) = rule.onNodeWithText(text, substring = true).assertExists()
|
||||||
|
|
||||||
|
private fun assertNotShown(text: String) = rule.onNodeWithText(text, substring = true).assertDoesNotExist()
|
||||||
|
|
||||||
|
private fun assertJellyfinRatingNotShown() {
|
||||||
|
assertNotShown("7.8")
|
||||||
|
assertNotShown("59%")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun movieShowsRottenTomatoesThenImdbAndNotItsJellyfinRating() {
|
||||||
|
movieRatings()
|
||||||
|
show(item(BaseItemKind.MOVIE, 603))
|
||||||
|
assertShown("96%")
|
||||||
|
assertShown("90%")
|
||||||
|
assertShown("8.8")
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seriesShowsItsRatings() {
|
||||||
|
seriesRatings()
|
||||||
|
show(item(BaseItemKind.SERIES, seriesTmdb))
|
||||||
|
assertShown("81%")
|
||||||
|
assertShown("45%")
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeAndSeasonShowTheirSeriesRatings() {
|
||||||
|
seriesRatings()
|
||||||
|
show(item(BaseItemKind.EPISODE, seriesId = seriesId))
|
||||||
|
assertShown("81%")
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun episodeWithScores(): BaseItem {
|
||||||
|
seriesRatings()
|
||||||
|
coEvery { tvApi.tvTvIdSeasonSeasonNumberGet(tvId = seriesTmdb, seasonNumber = 5) } returns
|
||||||
|
SeasonDetails(episodes = listOf(SeasonEpisode(episodeNumber = 1, voteAverage = 8.1.toBigDecimal(), voteCount = 50)))
|
||||||
|
return BaseItem(
|
||||||
|
BaseItemDto(
|
||||||
|
id = UUID.randomUUID(),
|
||||||
|
type = BaseItemKind.EPISODE,
|
||||||
|
providerIds = mapOf("Tmdb" to "331212"),
|
||||||
|
seriesId = seriesId,
|
||||||
|
parentIndexNumber = 5,
|
||||||
|
indexNumber = 1,
|
||||||
|
communityRating = 7.8f,
|
||||||
|
criticRating = 59f,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeShowsItsOwnLabelledTmdbScoreNextToTheSeriesRottenTomatoes() {
|
||||||
|
show(episodeWithScores())
|
||||||
|
assertShown("81%")
|
||||||
|
assertShown("45%")
|
||||||
|
assertShown("Ep 8.1")
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeScoreFollowsTheCommunityRatingSetting() {
|
||||||
|
show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING))
|
||||||
|
assertShown("81%")
|
||||||
|
assertNotShown("Ep 8.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun episodeScoreStaysWhenRottenTomatoesIsHidden() {
|
||||||
|
show(episodeWithScores(), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING))
|
||||||
|
assertShown("Ep 8.1")
|
||||||
|
assertNotShown("81%")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun seasonShowsItsSeriesRatings() {
|
||||||
|
seriesRatings()
|
||||||
|
show(item(BaseItemKind.SEASON, seriesId = seriesId))
|
||||||
|
assertShown("45%")
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun surfacesWithoutAPwaEquivalentShowNoRatingNotTheJellyfinStar() {
|
||||||
|
movieRatings()
|
||||||
|
seriesRatings()
|
||||||
|
show(
|
||||||
|
*listOf(
|
||||||
|
BaseItemKind.BOX_SET,
|
||||||
|
BaseItemKind.MUSIC_ALBUM,
|
||||||
|
BaseItemKind.MUSIC_ARTIST,
|
||||||
|
BaseItemKind.PHOTO,
|
||||||
|
BaseItemKind.PROGRAM,
|
||||||
|
).map { item(it, 603, seriesId) }.toTypedArray(),
|
||||||
|
)
|
||||||
|
assertNotShown("96%")
|
||||||
|
assertNotShown("81%")
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun titleSeerrHasNoRatingsForShowsNoRatingNotTheJellyfinStar() {
|
||||||
|
coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns MovieMovieIdRatingscombinedGet200Response()
|
||||||
|
coEvery { moviesApi.movieMovieIdGet(movieId = 5) } returns
|
||||||
|
mockk {
|
||||||
|
every { voteAverage } returns null
|
||||||
|
every { voteCount } returns null
|
||||||
|
}
|
||||||
|
show(item(BaseItemKind.MOVIE, 5))
|
||||||
|
assertJellyfinRatingNotShown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDisplaySettingsStillControlWhichSourcesAreDrawn() {
|
||||||
|
movieRatings()
|
||||||
|
show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.COMMUNITY_RATING))
|
||||||
|
assertShown("8.8")
|
||||||
|
assertNotShown("96%")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun criticSettingHidesImdb() {
|
||||||
|
movieRatings()
|
||||||
|
show(item(BaseItemKind.MOVIE, 603), toggles = EnumSet.of(DisplayToggle.CRITIC_RATING))
|
||||||
|
assertShown("96%")
|
||||||
|
assertNotShown("8.8")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ratingsTextHasOneBadgeAndNumberPerEntryInOrder() {
|
||||||
|
val ratings =
|
||||||
|
SourceRatings.of(
|
||||||
|
rtCriticsScore = 96,
|
||||||
|
rtCriticsLabel = "Certified Fresh",
|
||||||
|
rtAudienceScore = 30,
|
||||||
|
rtAudienceLabel = "Spilled",
|
||||||
|
imdbScore = 8.8,
|
||||||
|
imdbVotes = 10,
|
||||||
|
)
|
||||||
|
val entries = ratings.visibleEntries(community = true, critic = true)
|
||||||
|
val text = ratingsText(entries, leadingDot = false, episodeLabel = "Ep")
|
||||||
|
|
||||||
|
assertTrue(text.text.indexOf("96%") < text.text.indexOf("30%"))
|
||||||
|
assertTrue(text.text.indexOf("30%") < text.text.indexOf("8.8"))
|
||||||
|
// dots only between entries, or also in front when something precedes the ratings
|
||||||
|
assertEquals(2, text.text.split("\u2022").size - 1)
|
||||||
|
assertEquals(3, ratingsText(entries, leadingDot = true, episodeLabel = "Ep").text.split("\u2022").size - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue