diff --git a/Intents.md b/Intents.md index 7f01a501..7e358955 100644 --- a/Intents.md +++ b/Intents.md @@ -33,7 +33,7 @@ Full format ```bash adb shell am start \ -a android.intent.action.SEARCH \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es query "Avengers" ``` @@ -47,7 +47,7 @@ Multi-word query: # Whole shell command is wrapped in double quotes adb shell "am start \ -a android.intent.action.SEARCH \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es query 'Avengers Endgame'" adb shell am start -d 'wholphin://search?query=Avengers+Endgame' @@ -72,7 +72,7 @@ Full format ```bash adb shell am start \ -a android.intent.action.VIEW \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" ``` @@ -98,7 +98,7 @@ Full format ```bash adb shell am start \ -a com.github.damontecres.wholphin.PLAYBACK \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" ``` @@ -111,7 +111,7 @@ Start at 4m30s: ```bash adb shell am start \ -a com.github.damontecres.wholphin.PLAYBACK \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ --el position 270000 @@ -122,7 +122,7 @@ Shuffle a series (`itemId` is the Series ID): ```bash adb shell am start \ -a com.github.damontecres.wholphin.PLAYBACK \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ --ez shuffle true @@ -138,7 +138,7 @@ Full format # Switch user, go to item adb shell am start \ -a android.intent.action.VIEW \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" \ --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" @@ -146,7 +146,7 @@ adb shell am start \ # Switch user, go to home screen adb shell am start \ -a android.intent.action.VIEW \ - -n 'com.github.damontecres.wholphin/.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ --es serverId "341880f9-ca88-4038-8718-f6b2407950b1" \ --es userId "5c8575e0-468d-44fb-8c70-00aa79160587" ``` @@ -163,6 +163,6 @@ Debug builds have a different package, so you must specify the component by its ```bash adb shell am start \ -a android.intent.action.VIEW \ - -n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \ + -n 'org.justinvisser.wolfskin.tv.debug/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" ``` diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..df6bd824 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.DEFAULT_GOAL := apk +WOLFSKIN_BUILD_IMAGE ?= wolfskin-build:sdk37-jdk21 +WOLFSKIN_GRADLE_VOLUME ?= wolfskin-gradle +WOLFSKIN_SECRETS_DIR ?= /opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin +export WOLFSKIN_BUILD_IMAGE WOLFSKIN_GRADLE_VOLUME WOLFSKIN_SECRETS_DIR + +.PHONY: build-image apk apk-debug test release-tag +build-image: + docker build --platform linux/amd64 --tag "$(WOLFSKIN_BUILD_IMAGE)" docker + +apk: build-image + @bash docker/gradle.sh release clean assembleDefaultRelease + +apk-debug: build-image + @bash docker/gradle.sh debug assembleDefaultDebug + +test: build-image + @bash docker/gradle.sh debug testDefaultDebugUnitTest + +release-tag: + @bash docker/release-tag.sh diff --git a/README.md b/README.md index ed82692f..b8f04e12 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,15 @@ +# Wolfskin + +Wolfskin is a personal fork of [Wholphin](https://github.com/damontecres/Wholphin) by damontecres. + +Licensed under GPL-2.0, the same licence as upstream (see [LICENSE](LICENSE)). + +Wolfskin is an Android TV client for one household. It is released through Forgejo releases on this repo, and it tracks upstream by merging each upstream release tag. + +The app uses Wolfskin launcher artwork and a lilac-on-`#07070a` theme by default on a fresh install. Existing theme choices and all upstream colour themes remain available under Settings → App theme. Settings → About credits and links to Wholphin by damontecres. + +--- + # Wholphin - an OSS Android TV client for Jellyfin > "Never half-phin two jellies. Always wholphin one jelly." diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8e8b259f..96883e40 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,7 @@ configure { compileSdk = libs.versions.compileSdk.getInt() defaultConfig { - applicationId = "com.github.damontecres.wholphin" + applicationId = "org.justinvisser.wolfskin.tv" minSdk = libs.versions.minSdk.getInt() targetSdk = libs.versions.targetSdk.getInt() versionCode = gitTags.trim().lines().size diff --git a/app/src/debug/res/values/strings.xml b/app/src/debug/res/values/strings.xml index 606329c9..cb6c15c6 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ - Wholphin (Debug) + Wolfskin (Debug) diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 87623e5e..dfc380e1 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt index c9eb61eb..a1410d0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PlaybackLifecycleObserver import com.github.damontecres.wholphin.services.RefreshRateService 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.SetupDestination import com.github.damontecres.wholphin.services.SetupNavigationManager @@ -59,12 +60,13 @@ import com.github.damontecres.wholphin.ui.CoilConfig import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.collectLatestIn 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.nav.Destination import com.github.damontecres.wholphin.ui.playback.PlayExternalViewModel import com.github.damontecres.wholphin.ui.showToast import com.github.damontecres.wholphin.ui.theme.WholphinTheme -import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler @@ -118,6 +120,9 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var imageUrlService: ImageUrlService + @Inject + lateinit var seerrRatingsService: SeerrRatingsService + @Inject lateinit var refreshRateService: RefreshRateService @@ -229,7 +234,7 @@ class MainActivity : AppCompatActivity() { viewModel.appStart(intent) setContent { - MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) { + MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) { Surface(Modifier.fillMaxSize()) { val userPreferences by userPreferencesService.flow.collectAsState(null) if (userPreferences == null) { @@ -262,7 +267,10 @@ class MainActivity : AppCompatActivity() { LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } - CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { + CompositionLocalProvider( + LocalImageUrlService provides imageUrlService, + LocalSeerrRatingsService provides seerrRatingsService, + ) { WholphinTheme( true, appThemeColors = appPreferences.interfacePreferences.appThemeColors, diff --git a/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt b/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt new file mode 100644 index 00000000..aa023e97 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt @@ -0,0 +1,9 @@ +package com.github.damontecres.wholphin + +/** Wolfskin-specific defaults kept in one place to keep upstream diffs small. */ +object WolfskinDefaults { + const val SEERR_URL = "https://seerr.justinvisser.org" +} + +/** Returns [url] when it has content, otherwise the Wolfskin Seerr URL so the user can still edit it. */ +fun seerrUrlPrefill(url: String = ""): String = if (url.isBlank()) WolfskinDefaults.SEERR_URL else url diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt index b46a5f33..5139fb73 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/MostRecentServer.kt @@ -16,7 +16,7 @@ import java.util.UUID /** * Represents the status of the most recently used server * - * This can be read/written to shared preferences + * This can be saved/retrieved from a [MostRecentServerProvider] */ sealed interface MostRecentServer { /** @@ -58,12 +58,12 @@ sealed interface MostRecentServer { interface MostRecentServerProvider { /** - * Get the [MostRecentServer] from the shared preferences in [context] + * Get the [MostRecentServer] from this provider */ fun get(): MostRecentServer /** - * Save the [CurrentUser] as the most recent server into the shared preferences in [context] + * Save the [CurrentUser] as the most recent server */ suspend fun save(current: CurrentUser) @@ -86,9 +86,6 @@ class MostRecentServerSharedPreferences( ) : MostRecentServerProvider { private val prefs = getServerSharedPreferences(context) - /** - * Get the [MostRecentServer] from the shared preferences in [context] - */ override fun get(): MostRecentServer { val serverId = prefs.getString(SERVER_ID_KEY, null)?.toUUIDOrNull() val serverUrl = prefs.getString(SERVER_URL_KEY, null) @@ -103,9 +100,6 @@ class MostRecentServerSharedPreferences( } } - /** - * Save the [CurrentUser] as the most recent server into the shared preferences in [context] - */ override suspend fun save(current: CurrentUser) { prefs.edit(true) { putString(SERVER_ID_KEY, current.server.id.toServerString()) diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 32e588d7..256ccd65 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -1,6 +1,5 @@ package com.github.damontecres.wholphin.data.model -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable 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.CollectionType import org.jellyfin.sdk.model.extensions.ticks -import java.util.Locale import java.util.UUID import kotlin.time.Duration @@ -235,26 +233,6 @@ data class BaseItem( 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") - } - } - }, ), ) @@ -370,8 +348,6 @@ data class BaseItemUi( data class QuickDetailsData( val basic: AnnotatedString, val officialRating: AnnotatedString? = null, - val criticRating: AnnotatedString? = null, - val communityRating: AnnotatedString? = null, ) /** diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index 24d7d340..fe4650a6 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -3,8 +3,6 @@ package com.github.damontecres.wholphin.data.model import androidx.compose.runtime.Stable -import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response -import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response import com.github.damontecres.wholphin.ui.detail.CardGridItem import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.util.LocalDateSerializer @@ -123,20 +121,3 @@ data class DiscoverItem( } } } - -/** - * A rating for a discovered item which is usually fetched separately from the item - */ -data class DiscoverRating( - val criticRating: Int?, - val audienceRating: Float?, -) { - constructor(rating: MovieMovieIdRatingsGet200Response) : this( - criticRating = rating.criticsScore, - audienceRating = rating.audienceScore?.div(10f), - ) - constructor(rating: TvTvIdRatingsGet200Response) : this( - criticRating = rating.criticsScore, - audienceRating = null, - ) -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt new file mode 100644 index 00000000..581a4aa2 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt @@ -0,0 +1,231 @@ +package com.github.damontecres.wholphin.data.model + +import androidx.compose.runtime.Immutable +import java.util.Locale + +/** + * Which Rotten Tomatoes mark to draw. Chosen only from the label Seerr reports, never from a percentage. + */ +enum class RtMark { + FRESH, + ROTTEN, + UPRIGHT, + SPILLED, + + /** Seerr sent a score without a recognised label */ + UNKNOWN, + ; + + companion object { + /** + * Certified Fresh/Fresh -> [FRESH], Rotten -> [ROTTEN], Upright -> [UPRIGHT], Spilled -> [SPILLED], anything else null + */ + fun fromLabel(label: String?): RtMark? = + when (label) { + "Certified Fresh", "Fresh" -> FRESH + "Rotten" -> ROTTEN + "Upright" -> UPRIGHT + "Spilled" -> SPILLED + else -> null + } + } +} + +@Immutable +data class RtScore( + /** 0-100 */ + val percent: Int, + val mark: RtMark, +) + +/** + * 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 +sealed interface RatingEntry { + val text: String + val badge: RatingBadge + + /** + * An episode's own score rather than its series', which is drawn with a label so it is not mistaken for the series + */ + val isEpisode: Boolean get() = false + + data class RottenTomatoes( + val score: RtScore, + /** Critics (Tomatometer) or audience (Popcornmeter) */ + val critics: Boolean, + ) : RatingEntry { + override val text: String get() = "${score.percent}%" + override val badge: RatingBadge + get() = + when (score.mark) { + RtMark.FRESH -> RatingBadge.RT_FRESH + RtMark.ROTTEN -> RatingBadge.RT_ROTTEN + RtMark.UPRIGHT -> RatingBadge.RT_UPRIGHT + RtMark.SPILLED -> RatingBadge.RT_SPILLED + RtMark.UNKNOWN -> RatingBadge.RT_UNKNOWN + } + } + + data class Imdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + override val badge: RatingBadge get() = RatingBadge.IMDB + } + + data class Tmdb( + val score: Double, + ) : RatingEntry { + override val text: String get() = formatScore(score) + 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 + } +} + +private fun formatScore(score: Double) = String.format(Locale.getDefault(), "%.1f", score) + +/** + * Ratings for a single title from the same sources as the Wolfskin PWA: Rotten Tomatoes critics & audience, + * IMDb (movies only), and TMDB. + * + * Only scores that were actually observed are present. Use [of] to apply the PWA's rules. + */ +@Immutable +data class SourceRatings( + val rtCritics: RtScore? = null, + val rtAudience: RtScore? = null, + val imdb: Double? = null, + val tmdb: Double? = null, + /** 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 && episodeTmdb == null + + /** + * Rotten Tomatoes entries in PWA order: critics then audience + */ + val criticEntries: List + get() = + listOfNotNull( + rtCritics?.let { RatingEntry.RottenTomatoes(it, true) }, + rtAudience?.let { RatingEntry.RottenTomatoes(it, false) }, + ) + + /** + * IMDb or TMDB + */ + val communityEntries: List + get() = + listOfNotNull( + imdb?.let { RatingEntry.Imdb(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 = (if (critic) criticEntries else emptyList()) + (if (community) communityEntries else emptyList()) + + companion object { + const val MAX_PERCENT = 100.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: + * - Only observed scores are kept. A score outside its range (RT 0-100, IMDb/TMDB 0-10) or with a negative vote count is dropped + * - A zero-vote IMDb/TMDB score is unrated, never 0 + * - RT icon comes from the label Seerr reports + * - TMDB is kept only when no other source has a score + * + * @param rtCriticsLabel Seerr's critics status: Certified Fresh, Fresh, or Rotten + * @param rtAudienceLabel Seerr's audience status: Upright or Spilled + */ + fun of( + rtCriticsScore: Number? = null, + rtCriticsLabel: String? = null, + rtAudienceScore: Number? = null, + rtAudienceLabel: String? = null, + imdbScore: Number? = null, + imdbVotes: Long? = null, + tmdbScore: Number? = null, + tmdbVotes: Long? = null, + ): SourceRatings { + val critics = rtScore(rtCriticsScore, rtCriticsLabel, RtMark.UNKNOWN) + val audience = rtScore(rtAudienceScore, rtAudienceLabel, RtMark.UPRIGHT) + val imdb = userScore(imdbScore, imdbVotes) + val tmdb = userScore(tmdbScore, tmdbVotes) + return if (critics != null || audience != null || imdb != null) { + SourceRatings(critics, audience, imdb, null) + } else { + SourceRatings(tmdb = tmdb) + } + } + + private fun rtScore( + score: Number?, + label: String?, + defaultMark: RtMark, + ): RtScore? { + val value = score?.toDouble() ?: return null + if (value.isNaN() || value < 0.0 || value > MAX_PERCENT) return null + return RtScore(value.toInt(), RtMark.fromLabel(label) ?: defaultMark) + } + + private fun userScore( + score: Number?, + votes: Long?, + ): Double? { + val value = score?.toDouble() ?: return null + if (value.isNaN() || value < 0.0 || value > MAX_SCORE) return null + if (votes != null && votes <= 0L) return null + return value + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt index 68653272..63761d99 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/preferences/AppPreference.kt @@ -557,7 +557,7 @@ sealed interface AppPreference { val ThemeColors = AppChoicePreference( title = R.string.app_theme, - defaultValue = AppThemeColors.PURPLE, + defaultValue = AppThemeColors.WOLFSKIN, getter = { it.interfacePreferences.appThemeColors }, setter = { prefs, value -> prefs.updateInterfacePreferences { appThemeColors = value } @@ -627,7 +627,7 @@ sealed interface AppPreference { val UpdateUrl = AppStringPreference( title = R.string.update_url, - defaultValue = "https://api.github.com/repos/damontecres/Wholphin/releases/latest", + defaultValue = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest", getter = { it.updateUrl }, setter = { prefs, value -> prefs.update { updateUrl = value } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt index 4ce6583f..dbaa219e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/LatestNextUpService.kt @@ -6,7 +6,7 @@ package com.github.damontecres.wholphin.services 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.WholphinDispatchers import kotlinx.coroutines.async @@ -58,7 +58,7 @@ class LatestNextUpService val request = GetResumeItemsRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, limit = limit, mediaTypes = listOf(MediaType.VIDEO), excludeItemTypes = if (!includeEpisodes) listOf(BaseItemKind.EPISODE) else null, @@ -90,7 +90,7 @@ class LatestNextUpService val request = GetNextUpRequest( userId = userId, - fields = SlimItemFields, + fields = HomeSlimItemFields, imageTypeLimit = 1, parentId = null, limit = limit, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt new file mode 100644 index 00000000..08d31a9d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt @@ -0,0 +1,411 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.data.model.SourceRatings +import com.google.common.cache.CacheBuilder +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +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 timber.log.Timber +import java.util.Optional +import java.util.UUID +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The TMDB user vote average & count as reported by Seerr's movie/TV details + */ +data class TmdbVote( + val average: Double?, + val count: Long?, +) + +/** + * 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 + * `voteAverage`/`voteCount` and is only requested when no other source has a score. + * + * Results are cached by media type and TMDB id because movie and TV ids are separate namespaces. Failures are never cached. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@Singleton +class SeerrRatingsService + @Inject + constructor( + private val seerrService: SeerrService, + private val api: ApiClient, + ) { + private data class Key( + val type: SeerrItemType, + val tmdbId: Int, + ) + + private val cache = + CacheBuilder + .newBuilder() + .maximumSize(CACHE_SIZE) + .expireAfterWrite(CACHE_HOURS, TimeUnit.HOURS) + .build() + + 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>() + + /** + * 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>() + + fun cached( + type: SeerrItemType, + tmdbId: Int, + ): SourceRatings? = cache.getIfPresent(Key(type, tmdbId)) + + /** + * The Jellyfin item whose provider ids give the TMDB id, null if the item never has ratings. + * An episode's or season's own provider ids are for the episode or season, so those use the series. + */ + private fun ownerId(item: BaseItem): UUID? = + when (item.type) { + BaseItemKind.MOVIE, BaseItemKind.SERIES -> item.id + BaseItemKind.SEASON, BaseItemKind.EPISODE -> item.data.seriesId + else -> null + } + + /** + * 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? = + 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) } + } + + /** + * Finds the [RatingsTarget] for an item, asking Jellyfin for the provider ids if they are not at hand. + * Null if the item has no ratings, no TMDB id or the lookup failed. + */ + 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) } + } + + /** + * The already cached ratings for an item, used to seed a screen's state so it doesn't flash empty + */ + fun cachedFor(item: BaseItem): SourceRatings? { + val target = knownTarget(item) ?: return null + val series = cached(target.type, target.tmdbId) ?: return null + if (item.type != BaseItemKind.EPISODE) return series + 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 { + 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? { + 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 + } + + /** + * @param knownTmdb if the caller already has the TMDB vote data (ie from the details response), avoids another request + * @return the ratings (possibly [SourceRatings.isEmpty]) or null if they could not be determined right now + */ + suspend fun get( + type: SeerrItemType, + tmdbId: Int, + knownTmdb: TmdbVote? = null, + ): SourceRatings? { + val key = Key(type, tmdbId) + cache.getIfPresent(key)?.let { return it } + if (type != SeerrItemType.MOVIE && type != SeerrItemType.TV) return null + + val combined = + try { + fetchCombined(type, tmdbId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.w(ex, "Error fetching ratings for %s %s", type, tmdbId) + return null + } + var ratings = build(combined, null) + if (ratings.isEmpty) { + // Nothing else has a score, so TMDB is the only possible source + val vote = + knownTmdb ?: try { + fetchTmdb(type, tmdbId) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + Timber.w(ex, "Error fetching TMDB rating for %s %s", type, tmdbId) + return null + } + ratings = build(combined, vote) + } + cache.put(key, ratings) + return ratings + } + + private suspend fun fetchCombined( + type: SeerrItemType, + tmdbId: Int, + ): Combined = + try { + when (type) { + SeerrItemType.MOVIE -> { + Combined.Movie(seerrService.api.moviesApi.movieMovieIdRatingscombinedGet(movieId = tmdbId)) + } + + else -> { + Combined.Tv(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = tmdbId)) + } + } + } catch (ex: ClientException) { + if (ex.statusCode == 404) { + // Seerr has no RT/IMDb data for this title, that is an answer rather than a failure + Timber.d("No ratings for %s %s", type, tmdbId) + Combined.None + } else { + throw ex + } + } + + private suspend fun fetchTmdb( + type: SeerrItemType, + tmdbId: Int, + ): TmdbVote = + when (type) { + SeerrItemType.MOVIE -> { + seerrService.api.moviesApi + .movieMovieIdGet(movieId = tmdbId) + .let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) } + } + + else -> { + seerrService.api.tvApi + .tvTvIdGet(tvId = tmdbId) + .let { TmdbVote(it.voteAverage?.toDouble(), it.voteCount?.toLong()) } + } + } + + private fun build( + combined: Combined, + tmdb: TmdbVote?, + ): SourceRatings = + when (combined) { + is Combined.Movie -> buildMovie(combined.response, tmdb) + is Combined.Tv -> { + val rt = combined.response + SourceRatings.of( + rtCriticsScore = rt.criticsScore, + rtCriticsLabel = rt.criticsRating, + rtAudienceScore = rt.audienceScore, + rtAudienceLabel = rt.audienceRating, + tmdbScore = tmdb?.average, + tmdbVotes = tmdb?.count, + ) + } + + Combined.None -> { + SourceRatings.of(tmdbScore = tmdb?.average, tmdbVotes = tmdb?.count) + } + } + + private fun buildMovie( + response: MovieMovieIdRatingscombinedGet200Response, + tmdb: TmdbVote?, + ): SourceRatings = + SourceRatings.of( + rtCriticsScore = response.rt?.criticsScore, + rtCriticsLabel = response.rt?.criticsRating, + rtAudienceScore = response.rt?.audienceScore, + rtAudienceLabel = response.rt?.audienceRating, + imdbScore = response.imdb?.criticsScore, + imdbVotes = response.imdb?.criticsScoreCount, + tmdbScore = tmdb?.average, + tmdbVotes = tmdb?.count, + ) + + private sealed interface Combined { + data class Movie( + val response: MovieMovieIdRatingscombinedGet200Response, + ) : Combined + + data class Tv( + val response: TvTvIdRatingsGet200Response, + ) : Combined + + data object None : Combined + } + + companion object { + private const val CACHE_SIZE = 300L + private const val CACHE_HOURS = 6L + const val DEBOUNCE_MILLIS = 350L + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt index a00de8e4..706285f2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/UpdateChecker.kt @@ -57,7 +57,7 @@ class UpdateChecker @param:StandardOkHttpClient private val okHttpClient: OkHttpClient, ) { companion object { - const val ASSET_NAME = "Wholphin" + const val ASSET_NAME = "Wolfskin" const val APK_NAME = "$ASSET_NAME.apk" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" @@ -65,6 +65,53 @@ class UpdateChecker private val NOTE_REGEX = Regex("") val ACTIVE = BuildConfig.UPDATING_ENABLED + + private const val RELEASES_URL = "https://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases?limit=50" + + /** + * Find the release in a Forgejo release list whose name parses to the given version + */ + fun findReleaseByVersion( + releases: JsonArray, + version: Version, + ): JsonObject? = + releases + .firstOrNull { release -> + val candidate = Version.tryFromString(release.jsonObject["name"]?.jsonPrimitive?.contentOrNull) + candidate != null && !candidate.isGreaterThan(version) && !version.isGreaterThan(candidate) + }?.jsonObject + + /** + * Parse a single release object (Forgejo or GitHub shape) into a [Release], or null if its name is not a version + */ + fun parseRelease( + json: JsonObject, + debug: Boolean, + supportedABIs: List = Build.SUPPORTED_ABIS.toList(), + ): Release? { + val name = json["name"]?.jsonPrimitive?.contentOrNull + val version = Version.tryFromString(name) + if (version == null) { + Timber.w("Update version parsing failed. name=$name") + return null + } + val publishedAt = json["published_at"]?.jsonPrimitive?.contentOrNull + val body = json["body"]?.jsonPrimitive?.contentOrNull + val downloadUrl = + json["assets"]?.jsonArray?.let { assets -> getDownloadUrl(assets, debug, supportedABIs) } + Timber.v("version=$version, downloadUrl=$downloadUrl") + val notes = + if (body.isNotNullOrBlank()) { + NOTE_REGEX + .findAll(body) + .map { m -> + m.groupValues[1] + }.toList() + } else { + emptyList() + } + return Release(version, downloadUrl, publishedAt, body, notes) + } } /** @@ -127,17 +174,29 @@ class UpdateChecker return Version.fromString(pkgInfo.versionName!!) } + /** + * Get the release whose name parses to the given version. + * + * Wolfskin releases are named by `git describe` (e.g. `v1.0.9-2-g3094fc8c`) rather than tagged `vX.Y.Z`, + * so this searches the release list instead of fetching a tag. + */ suspend fun getRelease(version: Version): Release? { - val url = - "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" + val request = + Request + .Builder() + .url(RELEASES_URL) + .get() + .build() return withContext(WholphinDispatchers.IO) { - val request = - Request - .Builder() - .url(url) - .get() - .build() - getRelease(request) + okHttpClient.newCall(request).execute().use { + if (it.isSuccessful) { + val releases = Json.parseToJsonElement(it.body.string()).jsonArray + findReleaseByVersion(releases, version)?.let { json -> parseRelease(json, BuildConfig.DEBUG) } + } else { + Timber.w("Release lookup failed ${it.code}: ${it.message}") + null + } + } } } @@ -158,36 +217,11 @@ class UpdateChecker private fun getRelease(request: Request): Release? { return okHttpClient.newCall(request).execute().use { if (it.isSuccessful) { - val result = Json.parseToJsonElement(it.body.string()) - val name = result.jsonObject["name"]?.jsonPrimitive?.contentOrNull - val version = Version.tryFromString(name) - val publishedAt = - result.jsonObject["published_at"]?.jsonPrimitive?.contentOrNull - val body = result.jsonObject["body"]?.jsonPrimitive?.contentOrNull - val downloadUrl = - result.jsonObject["assets"] - ?.jsonArray - ?.let { assets -> getDownloadUrl(assets, BuildConfig.DEBUG) } - Timber.v("version=$version, downloadUrl=$downloadUrl") - if (version != null) { - val notes = - if (body.isNotNullOrBlank()) { - NOTE_REGEX - .findAll(body) - .map { m -> - m.groupValues[1] - }.toList() - } else { - emptyList() - } - return@use Release(version, downloadUrl, publishedAt, body, notes) - } else { - Timber.w("Update version parsing failed. name=$name") - } + parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG) } else { Timber.w("Update check failed ${it.code}: ${it.message}") + null } - return@use null } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt index c7cbaf44..a284ef0b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/Formatting.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.Resources import android.text.format.DateFormat import androidx.annotation.StringRes -import androidx.compose.foundation.text.appendInlineContent import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.buildAnnotatedString import com.github.damontecres.wholphin.R @@ -242,30 +241,12 @@ val MediaSegmentType.skipStringRes: Int fun AnnotatedString.Builder.dot() = append(" \u2022 ") -fun listToDotString( - strings: List, - communityRating: Float?, - criticRating: Float?, -): AnnotatedString = +fun listToDotString(strings: List): AnnotatedString = buildAnnotatedString { strings.forEachIndexed { index, string -> append(string) if (index != strings.lastIndex) dot() } - communityRating?.let { - dot() - append(String.format(Locale.getDefault(), "%.1f", it)) - appendInlineContent(id = "star") - } - criticRating?.let { - dot() - append("${it.toInt()}%") - if (it >= 60f) { - appendInlineContent(id = "fresh") - } else { - appendInlineContent(id = "rotten") - } - } } @get:StringRes diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt index 2e57b407..786db3d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/UiConstants.kt @@ -75,12 +75,22 @@ val SlimItemFields = */ 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 = listOf( ItemFields.OVERVIEW, 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 = DetailItemFields + listOf( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt index ce8952a7..17649be9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/WatchedIcon.kt @@ -41,6 +41,7 @@ fun WatchedIconBackground(): Color = when (LocalTheme.current) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -55,6 +56,8 @@ fun WatchedIconBackground(): Color = @Composable fun WatchedIconColor(): Color = when (LocalTheme.current) { + AppThemeColors.WOLFSKIN -> MaterialTheme.colorScheme.onPrimary + AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, AppThemeColors.BLUE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt index d2661ae4..57ff8957 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/CollectionFolderList.kt @@ -251,7 +251,7 @@ fun CollectionFolderListDetails( ) item?.let { - QuickDetails(item.ui.quickDetails, null) + QuickDetails(item, null) } OverviewText( diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt index 6db89c75..902ce814 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/QuickDetails.kt @@ -1,30 +1,24 @@ package com.github.damontecres.wholphin.ui.components 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.NonRestartableComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources -import androidx.compose.ui.res.painterResource 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.Icon 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.QuickDetailsData +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.formatTime import com.github.damontecres.wholphin.ui.util.LocalClock @@ -32,6 +26,12 @@ import com.github.damontecres.wholphin.ui.util.LocalInterfaceCustomization import org.jellyfin.sdk.model.DateTime 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 fun QuickDetails( details: QuickDetailsData?, @@ -39,21 +39,16 @@ fun QuickDetails( modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, endsAt: DateTime? = null, + ratings: SourceRatings? = null, ) { val enabled = LocalInterfaceCustomization.current.enabledDisplayToggles - val inlineContentMap = rememberQuickDetailsContentMap(textStyle) Row(modifier = modifier) { if (details != null) { - QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap) + QuickDetailsText(details.basic, Modifier, textStyle) if (DisplayToggle.OFFICIAL_RATING in enabled) { - QuickDetailsText(details.officialRating, Modifier, textStyle, inlineContentMap) - } - if (DisplayToggle.COMMUNITY_RATING in enabled) { - QuickDetailsText(details.communityRating, Modifier, textStyle, inlineContentMap) - } - if (DisplayToggle.CRITIC_RATING in enabled) { - QuickDetailsText(details.criticRating, Modifier, textStyle, inlineContentMap) + QuickDetailsText(details.officialRating, Modifier, textStyle) } + SourceRatingsRow(ratings, Modifier, textStyle) } if (timeRemaining != null) { TimeRemaining(timeRemaining, textStyle = textStyle) @@ -63,76 +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 @Composable fun QuickDetailsText( str: AnnotatedString?, modifier: Modifier = Modifier, textStyle: TextStyle = MaterialTheme.typography.titleSmall, - inlineContentMap: Map = rememberQuickDetailsContentMap(textStyle), ) = str?.let { Text( text = str, color = MaterialTheme.colorScheme.onSurface, style = textStyle, - inlineContent = inlineContentMap, maxLines = 1, modifier = modifier, ) } -@Composable -fun rememberQuickDetailsContentMap(textStyle: TextStyle = MaterialTheme.typography.titleSmall) = - remember(textStyle) { - mapOf( - "star" to - InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter, - ), - ) { - Icon( - imageVector = Icons.Filled.Star, - tint = FilledStarColor, - contentDescription = null, - modifier = Modifier.fillMaxSize(), - ) - }, - "rotten" to - InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_rotten_tomatoes_rotten), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - tint = Color.Unspecified, - ) - }, - "fresh" to - InlineTextContent( - Placeholder( - textStyle.fontSize, - textStyle.fontSize, - PlaceholderVerticalAlign.TextCenter, - ), - ) { - Icon( - painter = painterResource(R.drawable.ic_rotten_tomatoes_fresh), - contentDescription = null, - modifier = Modifier.fillMaxSize(), - tint = Color.Unspecified, - ) - }, - ) - } - @Composable fun TimeRemaining( remaining: Duration, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt index c52fb52f..460eda05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/Rating.kt @@ -1,93 +1,29 @@ 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.Box -import androidx.compose.foundation.layout.Column 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.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.filled.Star 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.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.platform.LocalContext 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.tv.material3.Icon -import androidx.tv.material3.LocalContentColor import androidx.tv.material3.LocalTextStyle -import androidx.tv.material3.MaterialTheme 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.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 EmptyStarColor = Color(0xFF5D4A0A) -val ratingBarHeight: Dp = 32.dp - -@Composable -fun SimpleStarRating( - communityRating: Float?, - modifier: Modifier = Modifier, -) = CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { - SimpleStarRating( - text = communityRating?.let { String.format(Locale.getDefault(), "%.1f", it) }, - modifier = modifier, - ) -} - +/** + * A minimum rating such as "7+" with a star, for the rating filter menus. + * + * This is not for showing a title's rating, see [SourceRatingsRow] for that. + */ @Composable fun SimpleStarRating( 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) - } - } -} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt index 9021e213..f2addcd8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SliderBar.kt @@ -143,6 +143,7 @@ fun sliderActiveColor(focused: Boolean): Color { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -179,6 +180,7 @@ fun sliderInactiveColor(focused: Boolean): Color { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, -> { MaterialTheme.colorScheme.border .copy(alpha = .25f) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt new file mode 100644 index 00000000..19567883 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt @@ -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 { 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 = + 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, + 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, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 612c35a0..d0017d84 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -38,7 +38,7 @@ import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.LocalTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -183,7 +183,7 @@ fun DiscoverMovieDetailsContent( preferences: UserPreferences, userConfig: SeerrUserConfig?, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, canCancel: Boolean, people: List, trailers: List, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt index 1c0915ec..34bf4657 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -20,14 +20,15 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.MovieDetails -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.jellyfinId import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.HeaderUtils 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.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -44,7 +45,7 @@ import kotlin.time.Duration.Companion.minutes fun DiscoverMovieDetailsHeader( preferences: UserPreferences, movie: MovieDetails, - rating: DiscoverRating?, + rating: SourceRatings?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, showLogo: Boolean, @@ -79,7 +80,7 @@ fun DiscoverMovieDetailsHeader( ) { val resources = LocalResources.current val details = - remember(movie, rating, resources) { + remember(movie, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime @@ -105,15 +106,11 @@ fun DiscoverMovieDetailsHeader( ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) + QuickDetailsData(listToDotString(it)) } } - QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) + QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) movie.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(start = HeaderUtils.startPadding)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt index 17280298..407c6935 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrItemType @@ -19,9 +19,11 @@ import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.hasPermission import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.SeerrUserConfig +import com.github.damontecres.wholphin.services.TmdbVote import com.github.damontecres.wholphin.ui.equalsNotNull import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault @@ -60,6 +62,7 @@ class DiscoverMovieViewModel private val backdropService: BackdropService, val serverRepository: ServerRepository, val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val seerrServerRepository: SeerrServerRepository, @Assisted val item: DiscoverItem, ) : ViewModel() { @@ -108,11 +111,11 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val rating = - getDiscoverRating(item.id) { - DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id), - ) - } + ratingsService.get( + SeerrItemType.MOVIE, + item.id, + TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()), + ) _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -279,7 +282,7 @@ fun canUserCancelRequest( data class DiscoverMovieState( val movie: DataLoadingState = DataLoadingState.Pending, - val rating: DiscoverRating? = null, + val rating: SourceRatings? = null, val seasons: List = emptyList(), val trailers: List = emptyList(), val people: List = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt index afafbd2f..860af84e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -34,7 +34,8 @@ import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.QuickDetailsData +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.canRequestTv @@ -54,7 +55,7 @@ import com.github.damontecres.wholphin.ui.components.GenreText import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.components.LoadingPage 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.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -214,7 +215,7 @@ fun DiscoverSeriesDetailsContent( userConfig: SeerrUserConfig?, series: TvDetails, availability: SeerrAvailability, - rating: DiscoverRating?, + rating: SourceRatings?, canCancel: Boolean, seasons: List, similar: List, @@ -423,7 +424,7 @@ fun DiscoverSeriesDetailsContent( @Composable fun DiscoverSeriesDetailsHeader( series: TvDetails, - rating: DiscoverRating?, + rating: SourceRatings?, overviewOnClick: () -> Unit, showLogo: Boolean, modifier: Modifier = Modifier, @@ -454,7 +455,7 @@ fun DiscoverSeriesDetailsHeader( ) { val resources = LocalResources.current val details = - remember(series, rating, resources) { + remember(series, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -465,15 +466,11 @@ fun DiscoverSeriesDetailsHeader( ?.let { add(resources.formatDuration(it)) } // TODO }.let { - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) + QuickDetailsData(listToDotString(it)) } } - QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) + QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) series.genres?.mapNotNull { it.name }?.letNotEmpty { GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt index ab89b16d..c20ab9c9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutReques import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RequestStatus import com.github.damontecres.wholphin.data.model.SeerrAvailability @@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrServerRepository import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.services.TmdbVote import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.launchDefault import com.github.damontecres.wholphin.ui.launchIO @@ -60,6 +62,7 @@ class DiscoverSeriesViewModel private val backdropService: BackdropService, val serverRepository: ServerRepository, val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, private val seerrServerRepository: SeerrServerRepository, @Assisted val item: DiscoverItem, ) : ViewModel() { @@ -113,9 +116,11 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { val rating = - getDiscoverRating(item.id) { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) - } + ratingsService.get( + SeerrItemType.TV, + item.id, + TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()), + ) _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -449,7 +454,7 @@ class DiscoverSeriesViewModel data class DiscoverSeriesState( val tvSeries: DataLoadingState = DataLoadingState.Pending, - val rating: DiscoverRating? = null, + val rating: SourceRatings? = null, val seasons: List = emptyList(), val seasons4k: List = emptyList(), val trailers: List = emptyList(), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt index 3e625d60..cc89159f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverUtils.kt @@ -1,39 +1,16 @@ package com.github.damontecres.wholphin.ui.detail.discover import android.content.Context -import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException import com.github.damontecres.wholphin.api.seerr.model.MediaInfo -import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.jellyfinId import com.github.damontecres.wholphin.services.jellyfinIdAsString import com.github.damontecres.wholphin.ui.isNotNullOrBlank import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.showToast -import kotlinx.coroutines.CancellationException import org.jellyfin.sdk.model.api.BaseItemKind import timber.log.Timber -suspend fun getDiscoverRating( - id: Int, - func: suspend () -> DiscoverRating, -): DiscoverRating? = - try { - func.invoke() - } catch (ex: CancellationException) { - throw ex - } catch (ex: ClientException) { - if (ex.statusCode == 404) { - Timber.w("No rating for %s", id) - } else { - Timber.e(ex, "Error fetching rating for %s", id) - } - null - } catch (ex: Exception) { - Timber.e(ex, "Error fetching rating for %s", id) - null - } - /** * Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId] * diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt index e8872180..3c341ff2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/episode/EpisodeDetailsHeader.kt @@ -55,7 +55,7 @@ fun EpisodeDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { val padding = 8.dp - QuickDetails(ep.ui.quickDetails, ep.timeRemainingOrRuntime) + QuickDetails(ep, ep.timeRemainingOrRuntime) VideoStreamDetails( chosenStreams = chosenStreams, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt index ee8f74f0..6dbbf965 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetailsHeader.kt @@ -64,7 +64,7 @@ fun MovieDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - movie.ui.quickDetails, + movie, movie.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index d0fea506..9a8dd033 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -300,6 +300,7 @@ data class MovieState( val extras: List = emptyList(), val similar: List = emptyList(), val discovered: List = emptyList(), + /** Seerr ratings, null until they load or if unavailable */ val chosenStreams: ChosenStreams? = null, val canDelete: Boolean = false, ) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt index b63f1cbc..be7795f4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/FocusedEpisodeHeader.kt @@ -35,10 +35,10 @@ fun FocusedEpisodeHeader( ) { EpisodeName(dto, modifier = Modifier.padding(start = HeaderUtils.startPadding)) - ep?.ui?.quickDetails?.let { + ep?.let { QuickDetails( it, - ep.timeRemainingOrRuntime, + it.timeRemainingOrRuntime, Modifier.padding(start = HeaderUtils.startPadding), ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index f42961ad..0308468e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -674,7 +674,7 @@ fun SeriesDetailsHeader( modifier = Modifier.fillMaxWidth(.60f), ) { QuickDetails( - series.ui.quickDetails, + series, null, Modifier.padding(start = HeaderUtils.startPadding), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 568cece8..3bbc3a6b 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -890,5 +890,6 @@ data class SeriesState( val peopleInEpisode: PeopleInItem = PeopleInItem(), val discovered: List = emptyList(), val discoverSeries: DiscoverItem? = null, + /** Seerr ratings, null until they load or if unavailable */ val chosenStreams: ChosenStreams? = null, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index cd7c6a27..62075882 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -32,17 +32,17 @@ import androidx.lifecycle.viewModelScope import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.filter.DiscoverFilter import com.github.damontecres.wholphin.data.model.DiscoverItem -import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.QuickDetailsData import com.github.damontecres.wholphin.data.model.SeerrItemType +import com.github.damontecres.wholphin.data.model.SourceRatings import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrRatingsService import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.ui.components.Genre import com.github.damontecres.wholphin.ui.components.HeaderUtils import com.github.damontecres.wholphin.ui.data.RowColumn -import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.listToDotString import com.github.damontecres.wholphin.ui.main.HomePageHeader @@ -57,9 +57,9 @@ import com.github.damontecres.wholphin.ui.util.StringProvider import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.DiscoverRequestType import com.github.damontecres.wholphin.util.successValue -import com.google.common.cache.CacheBuilder import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.model.UUID @@ -73,11 +73,12 @@ class SeerrDiscoverViewModel constructor( @param:ApplicationContext private val context: Context, private val seerrService: SeerrService, + private val ratingsService: SeerrRatingsService, val navigationManager: NavigationManager, private val backdropService: BackdropService, ) : ViewModel() { val state = MutableStateFlow(DiscoverState()) - val rating = MutableStateFlow>(mapOf()) + val rating = MutableStateFlow, SourceRatings>>(mapOf()) init { viewModelScope.launchIO { @@ -246,50 +247,23 @@ class SeerrDiscoverViewModel } } - private val ratingCache = - CacheBuilder - .newBuilder() - .maximumSize(100) - .build() + private var ratingJob: Job? = null - // TODO this is not very efficient + /** + * Fetch the ratings for the focused item, cancelling any previous request that is still running + */ fun fetchRating( id: Int, type: SeerrItemType, ) { - viewModelScope.launchIO { - val cachedResult = ratingCache.getIfPresent(id) - if (cachedResult != null) { - return@launchIO + val key = type to id + if (key in rating.value) return + ratingJob?.cancel() + ratingJob = + viewModelScope.launchIO { + val result = ratingsService.get(type, id) ?: return@launchIO + rating.update { it + (key to result) } } - val result = - getDiscoverRating(id) { - when (type) { - SeerrItemType.MOVIE -> { - DiscoverRating( - seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id), - ) - } - - SeerrItemType.TV -> { - DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id)) - } - - SeerrItemType.PERSON -> { - DiscoverRating(null, null) - } - - SeerrItemType.UNKNOWN -> { - DiscoverRating(null, null) - } - } - } ?: DiscoverRating(null, null) - - ratingCache.put(id, result) - rating.update { - ratingCache.asMap().toMap() - } - } } } @@ -415,7 +389,7 @@ fun SeerrDiscoverPage( modifier = modifier, ) { val details = - remember(focusedItem, ratingMap) { + remember(focusedItem) { (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> buildList { focusedItem.item @@ -424,23 +398,18 @@ fun SeerrDiscoverPage( ?.toString() ?.let(::add) }.let { - val rating = focusedItem.id.let { ratingMap[it] } - val str = - listToDotString( - it, - rating?.audienceRating, - rating?.criticRating?.toFloat(), - ) - QuickDetailsData(str) + QuickDetailsData(listToDotString(it)) } } } + val focusedRatings = (focusedItem as? DiscoverFocusedItem.Item)?.let { ratingMap[it.item.type to it.id] } HomePageHeader( title = focusedItem?.title?.getString(), subtitle = focusedItem?.subtitle, overview = focusedItem?.overview, overviewTwoLines = false, quickDetails = details, + ratings = focusedRatings, timeRemaining = null, endsAt = null, showLogo = preferences.appPreferences.interfacePreferences.showLogos, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index bf24ae00..26cc96d5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -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.HomeRowViewOptions 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.ui.Cards 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.TitleOrLogo 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.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -571,6 +573,7 @@ fun HomePageHeader( overview = item?.data?.overview, overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails, + ratings = rememberSourceRatings(item), timeRemaining = item?.timeRemainingOrRuntime, endsAt = item?.data?.endDate, showLogo = showLogo, @@ -586,6 +589,7 @@ fun HomePageHeader( overview: String?, overviewTwoLines: Boolean, quickDetails: QuickDetailsData?, + ratings: SourceRatings?, timeRemaining: Duration?, endsAt: DateTime?, showLogo: Boolean, @@ -611,7 +615,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) val overviewModifier = Modifier .padding(0.dp) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt index 100b6caf..c26cb891 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/NavDrawer.kt @@ -771,6 +771,7 @@ fun navItemColor( when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt index e51ccfbb..24bd8ee9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModel.kt @@ -717,6 +717,7 @@ class PlaybackViewModel api.videosApi.getVideoStreamUrl( itemId = itemId, mediaSourceId = source.id, + liveStreamId = source.liveStreamId, static = true, tag = source.eTag, playSessionId = response.playSessionId, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt index e30484a4..0e388213 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/PreferencesContent.kt @@ -298,6 +298,9 @@ fun PreferencesContent( .fillMaxWidth() .padding(top = 8.dp, bottom = 4.dp), ) + if (group.title == R.string.about) { + WolfskinAbout() + } } val groupPreferences = group.preferences + diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt index daf6fa07..43e213ea 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/SwitchPreference.kt @@ -70,6 +70,7 @@ fun SwitchColors(): SwitchColors { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, + AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, -> { SwitchDefaults.colors() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt new file mode 100644 index 00000000..a0814180 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt @@ -0,0 +1,28 @@ +package com.github.damontecres.wholphin.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.TextButton + +/** Fork identity and a focusable link to the upstream project. */ +@Composable +fun WolfskinAbout() { + val uriHandler = LocalUriHandler.current + val upstreamUrl = stringResource(R.string.wolfskin_upstream_url) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(R.string.app_name_long), + style = MaterialTheme.typography.headlineMedium, + ) + TextButton(onClick = { uriHandler.openUri(upstreamUrl) }) { + Text(stringResource(R.string.wolfskin_upstream_credit)) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt index ddc171c9..4d3ff8d3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/UserList.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -88,19 +89,24 @@ fun UserList( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - val focusRequester = remember { FocusRequester() } - val firstFocusRequester = remember { FocusRequester() } - if (users.isNotEmpty()) { - LaunchedEffect(Unit) { focusRequester.tryRequestFocus() } + val listState = rememberLazyListState() + val initialFocusRequester = remember { FocusRequester() } + val initialIndex = + users.indexOfFirst { it.user.id == currentUser?.id }.takeIf { it >= 0 } ?: 0 + LaunchedEffect(users, currentUser?.id) { + if (users.isNotEmpty()) { + listState.scrollToItem(initialIndex) + initialFocusRequester.tryRequestFocus() + } } LazyRow( + state = listState, horizontalArrangement = Arrangement.spacedBy(24.dp), // Spacing to accommodate 20% scale contentPadding = PaddingValues(horizontal = 48.dp, vertical = 16.dp), // Increased padding to accommodate 20% scale modifier = Modifier .wrapContentWidth() - .focusRestorer(firstFocusRequester) - .focusRequester(focusRequester), + .focusRestorer(initialFocusRequester), ) { itemsIndexed(users) { index, user -> UserIconCard( @@ -108,7 +114,7 @@ fun UserList( isCurrentUser = user.user.id == currentUser?.id, onClick = { onSwitchUser.invoke(user.user) }, onLongClick = { showDeleteDialog = user }, - modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier, + modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier, ) } // Add User card - always rightmost diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt index 4a65b3d5..5faccc05 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServer.kt @@ -30,6 +30,7 @@ import androidx.compose.ui.unit.dp import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.seerrUrlPrefill import com.github.damontecres.wholphin.ui.PreviewTvSpec import com.github.damontecres.wholphin.ui.components.Button import com.github.damontecres.wholphin.ui.components.EditTextBox @@ -55,13 +56,13 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(initialUrl) } + var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { - if (initialUrl.isBlank()) { + if (url.isBlank()) { focusRequester.tryRequestFocus() } else { passwordFocusRequester.tryRequestFocus() @@ -174,7 +175,7 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(initialUrl) } + var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } var username by remember { mutableStateOf(username) } var password by remember { mutableStateOf("") } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt index cc68fe04..5be93b83 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/seerr/AddSeerrServerDialog.kt @@ -225,10 +225,6 @@ fun ChooseSeerrLoginType( title = stringResource(R.string.seerr_login), items = listOf( - DialogItem( - text = stringResource(R.string.api_key), - onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, - ), DialogItem( text = stringResource(R.string.seerr_jellyfin_user), onClick = { onChoose.invoke(SeerrAuthMethod.JELLYFIN) }, @@ -237,6 +233,10 @@ fun ChooseSeerrLoginType( text = stringResource(R.string.seerr_local_user), onClick = { onChoose.invoke(SeerrAuthMethod.LOCAL) }, ), + DialogItem( + text = stringResource(R.string.api_key), + onClick = { onChoose.invoke(SeerrAuthMethod.API_KEY) }, + ), ), ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt index 8ae95b25..8cce37e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/Theme.kt @@ -13,9 +13,10 @@ import com.github.damontecres.wholphin.ui.theme.colors.OledThemeColors import com.github.damontecres.wholphin.ui.theme.colors.OrangeThemeColors import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors import com.github.damontecres.wholphin.ui.theme.colors.RedThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors val LocalTheme = - compositionLocalOf { AppThemeColors.PURPLE } + compositionLocalOf { AppThemeColors.WOLFSKIN } fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = when (appThemeColors) { @@ -27,13 +28,14 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors AppThemeColors.RED -> RedThemeColors AppThemeColors.BROWN -> BrownThemeColors - AppThemeColors.UNRECOGNIZED -> PurpleThemeColors + AppThemeColors.WOLFSKIN -> WolfskinThemeColors + AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors } @Composable fun WholphinTheme( darkTheme: Boolean = true, - appThemeColors: AppThemeColors = AppThemeColors.PURPLE, + appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN, content: @Composable () -> Unit, ) { val themeColors = getThemeColors(appThemeColors) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt new file mode 100644 index 00000000..ee0a77b5 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt @@ -0,0 +1,59 @@ +package com.github.damontecres.wholphin.ui.theme.colors + +import androidx.compose.ui.graphics.Color +import com.github.damontecres.wholphin.ui.theme.ThemeColors + +/** Lilac accents on near-black surfaces, with upstream Purple available separately. */ +val WolfskinThemeColors = + object : ThemeColors { + private val background = Color(0xFF07070A) + private val lilac = Color(0xFFD2BCFF) + private val onLilac = Color(0xFF241334) + private val container = Color(0xFF382A49) + private val onContainer = Color(0xFFEADCFF) + private val surfaceVariant = Color(0xFF24202D) + + override val lightSchemeMaterial = PurpleThemeColors.lightSchemeMaterial + override val lightScheme = PurpleThemeColors.lightScheme + + override val darkSchemeMaterial = + PurpleThemeColors.darkSchemeMaterial.copy( + primary = lilac, + onPrimary = onLilac, + primaryContainer = container, + onPrimaryContainer = onContainer, + secondary = lilac, + onSecondary = onLilac, + secondaryContainer = container, + onSecondaryContainer = onContainer, + tertiary = lilac, + onTertiary = onLilac, + tertiaryContainer = container, + onTertiaryContainer = onContainer, + background = background, + surface = background, + surfaceVariant = surfaceVariant, + inversePrimary = Color(0xFF705090), + ) + + override val darkScheme = + PurpleThemeColors.darkScheme.copy( + primary = lilac, + onPrimary = onLilac, + primaryContainer = container, + onPrimaryContainer = onContainer, + secondary = lilac, + onSecondary = onLilac, + secondaryContainer = container, + onSecondaryContainer = onContainer, + tertiary = lilac, + onTertiary = onLilac, + tertiaryContainer = container, + onTertiaryContainer = onContainer, + background = background, + surface = background, + surfaceVariant = surfaceVariant, + inversePrimary = Color(0xFF705090), + border = lilac.copy(alpha = .75f), + ) + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index a82201a9..2f98fccc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -436,7 +436,7 @@ val requestSerializersModule = subclass(GetStudiosRequest::class) subclass(GetRecordingsRequest::class) subclass(GetLiveTvChannelsRequest::class) - subclass(GetArtistsHandler::class) - subclass(GetAlbumArtistsHandler::class) + subclass(GetArtistsRequest::class) + subclass(GetAlbumArtistsRequest::class) } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index d5bf5701..55553166 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -128,6 +128,7 @@ enum AppThemeColors { OLED_BLACK = 5; RED = 6; BROWN = 7; + WOLFSKIN = 8; } enum EdgeStyle{ diff --git a/app/src/main/res/drawable/ic_rating_imdb.xml b/app/src/main/res/drawable/ic_rating_imdb.xml new file mode 100644 index 00000000..6725d42f --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_imdb.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_rating_tmdb.xml b/app/src/main/res/drawable/ic_rating_tmdb.xml new file mode 100644 index 00000000..31c0be7d --- /dev/null +++ b/app/src/main/res/drawable/ic_rating_tmdb.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_rt_spilled.xml b/app/src/main/res/drawable/ic_rt_spilled.xml new file mode 100644 index 00000000..fea81f97 --- /dev/null +++ b/app/src/main/res/drawable/ic_rt_spilled.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_rt_upright.xml b/app/src/main/res/drawable/ic_rt_upright.xml new file mode 100644 index 00000000..e7166b39 --- /dev/null +++ b/app/src/main/res/drawable/ic_rt_upright.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml index 4c5f707b..ec3f55e3 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -1,9 +1,4 @@ - - - - - - + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 5b7669dc..4ac23262 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 8e05a03a..de1628cc 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index e6a5972a..df5faba9 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index f44d1093..415cf194 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index 9e9214e8..d9df942a 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 3766935e..dea8ff64 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner.png b/app/src/main/res/mipmap-xhdpi/ic_banner.png index 4fa742db..ee42b91e 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_banner.png and b/app/src/main/res/mipmap-xhdpi/ic_banner.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png b/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png index 52b72c47..ee42b91e 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png and b/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index ce75a196..924b40eb 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 22298ad1..e218f8bd 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 3f7c7e29..40674eff 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index e5cef34d..6f5be441 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index fafcb4f1..8c1935c7 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index 5c1a8e36..48a4e286 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 90341396..1cf49fb6 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index dbe5821e..afc41a8b 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 2ef5c7d0..122a33e0 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 7de6e9ac..d71123be 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -489,8 +489,8 @@ Vyplnit Vyplnit šířku Vyplnit výšku - Výchozí předvolba Wholphin - Wholphin kompaktní + Výchozí předvolba Wolfskin + Wolfskin kompaktní Obrázky náhledů seriálů Obrázky náhledů epizod Nejnižší diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 870d8c85..a6efeb91 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -538,8 +538,8 @@ Fyld Fyld bredde Fyld højde - Wholphin-standard - Wholphin kompakt + Wolfskin-standard + Wolfskin kompakt Seriens miniaturebilleder Episode-miniaturebilleder Laveste @@ -637,7 +637,7 @@ Vælg alle Adskil typer Foretræk logoer som titler - Wholphin er opdateret til %s + Wolfskin er opdateret til %s Fjern fra Fortsæt med at se Fjern serien fra Næste Vis serier fjernet fra Næste diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index c23c136a..7170a274 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -511,8 +511,8 @@ Animieren Breite ausfüllen Höhe ausfüllen - Wholphin Standard - Wholphin Kompakt + Wolfskin Standard + Wolfskin Kompakt Niedrigste Niedrig Mittel @@ -580,7 +580,7 @@ Fortfahren Verschiedene Arten Bevorzugt Logo statt Titel anzeigen - Wholphin aktualisiert auf %s + Wolfskin aktualisiert auf %s Von \"Weiterschauen\" entfernen Serie aus \"Als Nächstes anschauen\" entfernen Mehr anzeigen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c256d419..714ae611 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -599,7 +599,7 @@ Eliminar de la cola Separar por tipos Preferir logotipos para los títulos - Wholphin se ha actualizado a la versión %s + Wolfskin se ha actualizado a la versión %s Ver más Descubrir series Descubrir películas diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 075fd109..2008bf2c 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -535,8 +535,8 @@ Täida Täida laiuses Täida kõrguses - Wholphini vaikeseadistus - Wholphini kompaktne vaade + Wolfskini vaikeseadistus + Wolfskini kompaktne vaade Sarja pisipildid Osa pisipildid Vaikseim @@ -580,7 +580,7 @@ Jätka Eraldi tüübid Pealkirjade puhul eelista logode kuvamist - Wholphin on uuendatud versioonini %s + Wolfskin on uuendatud versioonini %s Eemalda „Jätka vaatamist“ valikust Eemalda sari valikust „Järgmisena esitamisel“ Vaata sarja, mis sai eemaldatud valikust „Järgmisena esitamisel“ @@ -758,4 +758,5 @@ Viga Teisenda Dolby Vision Profile 7 → 8.1 Muudab kahekihilise „Profile 7“ enne dekooderit ühekihiliseks profiiliks 8.1 ja esitab seda seadmetel otse, mille dekooder toetab profiili 8, kuid mitte profiili 7 + Esitaja allikas diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a7991c1a..85603d28 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -507,8 +507,8 @@ Remplir Largeur de remplissage Hauteur de remplissage - Wholphin Par défaut - Wholphin Compact + Wolfskin Par défaut + Wolfskin Compact Images miniatures de la série Images miniatures de l\'épisode Le plus bas @@ -599,7 +599,7 @@ Suivant Séparer par types Préférer les logos pour les titres - Wholphin a été mis à jour vers la version %s + Wolfskin a été mis à jour vers la version %s Voir plus Parcourir les séries Parcourir les films @@ -777,4 +777,9 @@ Profil utilisateur du serveur Paramètres par défaut du serveur Client Web utilisateur + Convertir le profil Dolby Vision 7 en 8.1 + Convertit le profil 7 double couche en profil 8.1 simple couche avant le décodeur et le lit directement, pour les appareils dont le décodeur prend en charge le profil 8 mais pas le profil 7 + Source de l\'artiste + Géré par le serveur + Erreur diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index b056a7be..1eb5b58b 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -593,8 +593,8 @@ Ispuni Ispuni širinu Ispuni visinu - Wholphin zadani - Wholphin zbijeni + Wolfskin zadani + Wolfskin zbijeni Slike sličica serije Slike sličica epizoda Najniža @@ -653,7 +653,7 @@ Odaberi sve Odvojene vrste Preferiraj prikaz logotipa za naslove - Wholphin ažuriran na %s + Wolfskin ažuriran na %s Ukloni iz \"Nastavi gledanje\" Ukloni seriju iz \"Slijedi\" Prikaži serije uklonjene iz \"Slijedi\" diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 43f0683a..623ef596 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -465,8 +465,8 @@ Foto Sertakan jenis Tampilkan gambar untuk jenis item - Wholphin Bawaan - Wholphin Ringkas + Wolfskin Bawaan + Wolfskin Ringkas Gambar Thumbnail Seri Gambar Thumbnail Episode Terendah @@ -561,7 +561,7 @@ Pisahkan jenis Buka artis Buka album - Wholphin diperbarui ke %s + Wolfskin diperbarui ke %s Hapus dari \"Lanjutkan menonton\" Hapus seri dari \"Berikutnya\" Lihat seri yang dihapus dari \"Berikutnya\" diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ce58ab95..be2f53e3 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -489,8 +489,8 @@ Riempi Riempi larghezza Riempi altezza - Wholphin predefinito - Wholphin Compatto + Wolfskin predefinito + Wolfskin Compatto Immagini miniatura serie Immagini miniatura episodio Minimo @@ -599,7 +599,7 @@ Continua Tipi separati Preferisci mostrare i loghi per i titoli - Wholphin aggiornato a %s + Wolfskin aggiornato a %s Rimuovi da Continua a guardare Rimuovi serie da Prossimo Visualizza serie rimosse da Prossimo diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index b0b8b34a..d05db022 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -517,8 +517,8 @@ מתיחה לתצוגה מתיחה לרוחב מתיחה לגובה - ברירת המחדל של Wholphin - Wholphin קומפקטי + ברירת המחדל של Wolfskin + Wolfskin קומפקטי תמונות־ממוזערות של סדרה תמונות־ממוזערות של פרק כחול עז @@ -594,7 +594,7 @@ בחר הכול סוגים נפרדים העדף להציג לוגו על פני כותרת - Wholphin עודכן ל־%s + Wolfskin עודכן ל־%s מחק מהמשך צפייה מחק סדרה מ־\"הבא בתור\" הצג סדרות שנמחקו מ־\"הבא בתור\" diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 55344e51..045e125f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -1,3 +1,3 @@ - \ No newline at end of file + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 9e26efcc..c8066cec 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -483,8 +483,8 @@ Fyll Fyll bredde Fyll høyde - Wholphin Standard - Wholphin Kompakt + Wolfskin Standard + Wolfskin Kompakt Serieminiatyrbilder Episodeminiatyrbilder Lavest @@ -573,7 +573,7 @@ Velg alle Seperate typer Foretrekk å vise logoer for titler - Wholphin oppdatert til %s + Wolfskin oppdatert til %s Fjern fra fortsett å se Fjern serier fra neste Vis serier fjernet fra neste diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 2d6bd0c4..1e4891ba 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -480,8 +480,8 @@ Vullen Vulbreedte Vulhoogte - Wholphin standaard - Wholphin compact + Wolfskin standaard + Wolfskin compact Laag Middel Hoog @@ -603,7 +603,7 @@ Experimentele instellingen inschakelen? Experimentele instellingen - Wholphin is geüpdatet naar %s + Wolfskin is geüpdatet naar %s Laatst afgespeeld Direct afspelen met de ingebouwde ExoPlayer Ondertitels inbranden/transcoderen op de server diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 55e08a8d..eb7a6fe7 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -478,8 +478,8 @@ Fyll Fyll breidde Fyll høgde - Wholphin-standard - Wholphin-kompakt + Wolfskin-standard + Wolfskin-kompakt Miniatyrbilete for seriar Miniatyrbilete for episodar Lågast diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 98c5c2f3..3bb8567e 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -386,8 +386,8 @@ Wypełnij Pełna szerokość Pełna wysokość - Domyślny Wholphin - Kompaktowy Wholphin + Domyślny Wolfskin + Kompaktowy Wolfskin Miniatury seriali Zdjęcie odcinków Najniższa @@ -576,7 +576,7 @@ Wybierz wszystkie Oddzielne typy Wyświetlanie logo zamiast tytułu - Wholphin zaktualizowany do %s + Wolfskin zaktualizowany do %s Usuń z Kontynuuj oglądanie Usuń serial z Następnie Zobacz serię usuniętą z Następnie @@ -800,4 +800,9 @@ Profil użytkownika na serwerze Domyślnie z serwera Użytkownik klienta webowego + Konwertuj Dolby Vision 7 do 8.1 + W przypadku urządzeń, których dekoder obsługuje profil 8, ale nie obsługuje profilu 7, system przekształca dwuwarstwowy profil 7 na jednowarstwowy profil 8.1 przed przekazaniem go do dekodera, umożliwiając bezpośrednie odtwarzanie + Źródło artysty + Zarządzane przez serwer + Błąd diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e42abda3..af1e0d9f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -498,8 +498,8 @@ Preencher Preencher largura Preencher altura - Padrão do Wholphin - Wholphin Compacto + Padrão do Wolfskin + Wolfskin Compacto Prévia das imagens das séries Prévias das imagens do episódio Mais baixo @@ -605,7 +605,7 @@ Selecionar todos Tipos separados Preferir a exibição de logos para títulos - Wholphin atualizado para %s + Wolfskin atualizado para %s Remover de continuar assistindo Remover série de a seguir Ver séries removidas de a seguir diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 278e4ce4..f65e53af 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -507,8 +507,8 @@ Preencher Preencher largura Preencher altura - Padrão do Wholphin - Wholphin Compacto + Padrão do Wolfskin + Wolfskin Compacto Imagens em miniatura da série Imagens em miniatura do episódio Mínimo @@ -599,7 +599,7 @@ Continuar Separar por tipos Prefiro mostrar logótipos nos títulos - Wholphin foi atualizado para %s + Wolfskin foi atualizado para %s Remover de «Continuar a ver» Remover série do «Continuar a ver» Ver séries removidas do «Continuar a ver» diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d980fd9d..ca59ccb6 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -605,8 +605,8 @@ Растянуть По ширине По высоте - Wholphin по умолчанию - Wholphin компактный + Wolfskin по умолчанию + Wolfskin компактный Миниатюры сериалов Миниатюры эпизодов Самая низкая @@ -683,7 +683,7 @@ Продолжить Разделять по типам Показывать логотипы вместо текстовых названий - Wholphin обновлён до версии %s + Wolfskin обновлён до версии %s Убрать из «Продолжить просмотр» Убрать сериал из «Далее» Показать сериалы, убранные из «Далее» diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 23cd8aeb..50d2edd7 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -526,8 +526,8 @@ Vyplniť Vyplniť na šírku Vyplniť na výšku - Wholphin predvolené - Wholphin kompaktné + Wolfskin predvolené + Wolfskin kompaktné Náhľadové obrázky seriálov Náhľadové obrázky epizód Náhľad @@ -662,7 +662,7 @@ Pokračovať Oddeliť typy Uprednostniť zobrazovanie log namiesto názvov - Wholphin bol aktualizovaný na verziu %s + Wolfskin bol aktualizovaný na verziu %s Odstrániť z Pokračovať v pozeraní Odstrániť seriál zo sekcie Nasleduje Zobraziť seriály odstránené zo sekcie Nasleduje diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 46ec0b68..5711dac1 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -600,7 +600,7 @@ Izberi vse Ločene vrste Prikazuj logotipe za naslove - Wholphin posodobljen na %s + Wolfskin posodobljen na %s Odstrani iz nadaljevanja gledanja Odstrani serijo iz nadaljevanja gledanja Ogled serije, odstranjene iz naslednjega gledanja diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index a2e7df01..002d5ece 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -424,8 +424,8 @@ Välj rader och bilder på startsidan Är du säker på att du vill radera detta objekt? Upptäck - Wholphin Kompakt - Wholphin Standard + Wolfskin Kompakt + Wolfskin Standard Visningsförinställningar Inbyggda förinställningar för att snabbt styla alla rader Avsnittsminiatyrer @@ -595,7 +595,7 @@ Vilka typer av objekt ska bilder visas för Låten har text Visa omslag till album - Wholphin har uppdaterats till %s + Wolfskin har uppdaterats till %s Senast visat Huvudkatalog Populäritet diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index c544add8..2ccccbae 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -465,8 +465,8 @@ ยืดเต็มจอ ยืดตามความกว้าง ยืดตามความสูง - Wholphin ค่าเริ่มต้น - Wholphin คอมแพค + Wolfskin ค่าเริ่มต้น + Wolfskin คอมแพค ภาพขนาดย่อของซีรี่ส์ ภาพขนาดย่อของตอน เบาสุด diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9405ba27..70804da5 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -480,8 +480,8 @@ Doldur Genişliği Doldur Yüksekliği Doldur - Wholphin Varsayılan - Wholphin Kompakt + Wolfskin Varsayılan + Wolfskin Kompakt Dizi Küçük Resimleri Bölüm Küçük Resimleri En Düşük @@ -589,7 +589,7 @@ Devam et Farklı türler Başlık yerine logo göster - Wholphin %s sürümüne güncellendi + Wolfskin %s sürümüne güncellendi Daha fazla göster Dizileri Keşfet Filmleri Keşfet diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 6b518809..0c0c9727 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -567,7 +567,7 @@ Заповнити Заповнити по ширині Заповнити по висоті - Wholphin за замовчуванням + Wolfskin за замовчуванням Мініатюри серіалів Мініатюри серій Найнижча @@ -655,7 +655,7 @@ Вибрати все Розділяти типи Надавати перевагу логотипам для назв - Wholphin оновлено до %s + Wolfskin оновлено до %s Видалити з «Продовжити перегляд» Видалити серіал з «Далі» Переглянути серіали, видалені з «Далі» @@ -686,5 +686,5 @@ Томати Сортування Розділити на окремі рядки - Wholphin компактний + Wolfskin компактний diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index e36dc507..07b57d8e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -453,8 +453,8 @@ 填充 填充宽度 填充高度 - Wholphin 默认 - Wholphin 紧凑 + Wolfskin 默认 + Wolfskin 紧凑 剧集缩略图 分集缩略图 紫色 @@ -561,7 +561,7 @@ 继续 分离类型 优先显示标题图标 - Wholphin 已更新至 %s + Wolfskin 已更新至 %s 查看更多 发现电视节目 发现电影 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 09133145..c82ff0d8 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -482,8 +482,8 @@ 優先使用 MPV 僅 HDR 使用 ExoPlayer 播放 我的最愛的 %s - Wholphin 預設樣式 - Wholphin 緊湊樣式 + Wolfskin 預設樣式 + Wolfskin 緊湊樣式 劇集縮圖樣式 單集縮圖樣式 海報 (2:3) @@ -561,7 +561,7 @@ 即時混播 依類型分開顯示 優先顯示圖像標題 - Wholphin 已更新至 %s + Wolfskin 已更新至 %s 查看更多 探索電視劇 探索電影 diff --git a/app/src/main/res/values/ic_banner_background.xml b/app/src/main/res/values/ic_banner_background.xml deleted file mode 100644 index e0e3446a..00000000 --- a/app/src/main/res/values/ic_banner_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #000B25 - diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index ffea1b18..933396c0 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #101010 + #07070a diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cf49b425..3783bfdf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - Wholphin - Wholphin + Wolfskin + Wolfskin About Active Recordings @@ -165,6 +165,7 @@ S%1$02dE%2$02d S%1$02dE%2$02d-E%3$02d E%1$d + Ep Season %1$d TV Shows Interface @@ -603,8 +604,8 @@ Fill Fill width Fill height - Wholphin Default - Wholphin Compact + Wolfskin Default + Wolfskin Compact Series Thumb images Episode Thumbnail images @@ -636,6 +637,7 @@ @string/black @string/red @string/brown + @string/wolfskin_theme Ignore @@ -829,7 +831,7 @@ Select all Separate types Prefer showing logos for titles - Wholphin updated to %s + Wolfskin updated to %s Remove from continue watching Remove series from next up View series removed from next up diff --git a/app/src/main/res/values/wolfskin.xml b/app/src/main/res/values/wolfskin.xml new file mode 100644 index 00000000..2aca4d36 --- /dev/null +++ b/app/src/main/res/values/wolfskin.xml @@ -0,0 +1,6 @@ + + + Wolfskin + based on Wholphin by damontecres + https://github.com/damontecres/Wholphin + diff --git a/app/src/main/seerr/seerr-api.yml b/app/src/main/seerr/seerr-api.yml index 354f3298..dc493638 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1097,6 +1097,61 @@ components: example: 0 description: Availability of the season in 4K 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: type: string enum: ["Returning Series", "Planned", "In Production", "Ended", "Canceled", "Pilot"] @@ -6528,13 +6583,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined @@ -6573,13 +6628,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh', 'Certified Fresh'] + example: 'Certified Fresh' audienceScore: type: integer example: 65 audienceRating: type: string - enum: ['Spilled', 'Upright'] + example: 'Upright' imdb: type: object properties: @@ -6590,8 +6645,12 @@ paths: type: string example: 'https://www.imdb.com/title/tt0480249' criticsScore: + type: number + example: 8.8 + criticsScoreCount: type: integer - example: 6.5 + format: int64 + example: 2500000 /tv/{tvId}: get: summary: Get TV details @@ -6647,7 +6706,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Season' + $ref: '#/components/schemas/SeasonDetails' /tv/{tvId}/recommendations: get: summary: Get recommended TV series @@ -6773,7 +6832,13 @@ paths: example: 85 criticsRating: type: string - enum: ['Rotten', 'Fresh'] + example: 'Certified Fresh' + audienceScore: + type: integer + example: 65 + audienceRating: + type: string + example: 'Upright' /person/{personId}: get: summary: Get person details diff --git a/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt new file mode 100644 index 00000000..14d59cf2 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt @@ -0,0 +1,17 @@ +package com.github.damontecres.wholphin + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WolfskinDefaultsTest { + @Test + fun emptyUrlIsPrefilledWithWolfskinSeerrUrl() { + assertEquals(WolfskinDefaults.SEERR_URL, seerrUrlPrefill()) + assertEquals(WolfskinDefaults.SEERR_URL, seerrUrlPrefill(" ")) + } + + @Test + fun enteredUrlIsKept() { + assertEquals("https://seerr.example.org", seerrUrlPrefill("https://seerr.example.org")) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt new file mode 100644 index 00000000..e5c12a56 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt @@ -0,0 +1,204 @@ +package com.github.damontecres.wholphin.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SourceRatingsTest { + @Test + fun movieShowsRtCriticsRtAudienceAndImdbButNotTmdb() { + val ratings = + SourceRatings.of( + rtCriticsScore = 96, + rtCriticsLabel = "Certified Fresh", + rtAudienceScore = 90, + rtAudienceLabel = "Upright", + imdbScore = 8.8, + imdbVotes = 2_500_000, + tmdbScore = 8.4, + tmdbVotes = 30_000, + ) + assertEquals(RtScore(96, RtMark.FRESH), ratings.rtCritics) + assertEquals(RtScore(90, RtMark.UPRIGHT), ratings.rtAudience) + assertEquals(8.8, ratings.imdb!!, 0.0) + assertNull(ratings.tmdb) + } + + @Test + fun tmdbIsShownOnlyWhenNothingElseIs() { + val ratings = SourceRatings.of(tmdbScore = 7.633, tmdbVotes = 120) + assertEquals(7.633, ratings.tmdb!!, 0.0) + assertNull(ratings.rtCritics) + assertNull(ratings.imdb) + assertEquals(listOf("7.6"), ratings.communityEntries.map { it.text }) + + // A single non-TMDB source hides TMDB + assertNull(SourceRatings.of(rtAudienceScore = 40, tmdbScore = 7.0, tmdbVotes = 10).tmdb) + assertNull(SourceRatings.of(imdbScore = 5.0, imdbVotes = 10, tmdbScore = 7.0, tmdbVotes = 10).tmdb) + } + + @Test + fun zeroVoteScoresAreUnratedNotZero() { + val ratings = SourceRatings.of(imdbScore = 0.0, imdbVotes = 0, tmdbScore = 0.0, tmdbVotes = 0) + assertTrue(ratings.isEmpty) + + // A zero-vote IMDb score doesn't count as a source, so TMDB can still be the fallback + val withTmdb = SourceRatings.of(imdbScore = 6.5, imdbVotes = 0, tmdbScore = 7.1, tmdbVotes = 55) + assertNull(withTmdb.imdb) + assertEquals(7.1, withTmdb.tmdb!!, 0.0) + } + + @Test + fun missingVoteCountIsStillObserved() { + assertEquals(8.8, SourceRatings.of(imdbScore = 8.8).imdb!!, 0.0) + } + + @Test + fun outOfRangeScoresAreDropped() { + assertTrue(SourceRatings.of(rtCriticsScore = 101, rtAudienceScore = -1).isEmpty) + assertTrue(SourceRatings.of(imdbScore = 10.5, imdbVotes = 5, tmdbScore = -0.1, tmdbVotes = 5).isEmpty) + assertTrue(SourceRatings.of(imdbScore = 7.0, imdbVotes = -1).isEmpty) + } + + @Test + fun rtMarkComesFromSeerrLabelNotThePercentage() { + // 95% but labelled Rotten and 10% labelled Fresh: the label wins + assertEquals(RtMark.ROTTEN, SourceRatings.of(rtCriticsScore = 95, rtCriticsLabel = "Rotten").rtCritics!!.mark) + assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 10, rtCriticsLabel = "Fresh").rtCritics!!.mark) + assertEquals(RtMark.FRESH, SourceRatings.of(rtCriticsScore = 80, rtCriticsLabel = "Certified Fresh").rtCritics!!.mark) + assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 20, rtAudienceLabel = "Upright").rtAudience!!.mark) + assertEquals(RtMark.SPILLED, SourceRatings.of(rtAudienceScore = 90, rtAudienceLabel = "Spilled").rtAudience!!.mark) + } + + @Test + fun rtWithoutKnownLabelUsesPwaDefaults() { + assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90).rtCritics!!.mark) + assertEquals(RtMark.UNKNOWN, SourceRatings.of(rtCriticsScore = 90, rtCriticsLabel = "Mystery").rtCritics!!.mark) + assertEquals(RtMark.UPRIGHT, SourceRatings.of(rtAudienceScore = 90).rtAudience!!.mark) + } + + @Test + fun statusLabelMapping() { + assertEquals(RtMark.FRESH, RtMark.fromLabel("Certified Fresh")) + assertEquals(RtMark.FRESH, RtMark.fromLabel("Fresh")) + assertEquals(RtMark.ROTTEN, RtMark.fromLabel("Rotten")) + assertEquals(RtMark.UPRIGHT, RtMark.fromLabel("Upright")) + assertEquals(RtMark.SPILLED, RtMark.fromLabel("Spilled")) + assertNull(RtMark.fromLabel(null)) + assertNull(RtMark.fromLabel("fresh")) + } + + @Test + fun entriesUseThePwaTextAndBadges() { + val ratings = + SourceRatings.of( + rtCriticsScore = 85, + rtCriticsLabel = "Fresh", + rtAudienceScore = 40, + rtAudienceLabel = "Spilled", + imdbScore = 8.0, + imdbVotes = 1, + ) + assertEquals(listOf("85%", "40%"), ratings.criticEntries.map { it.text }) + assertEquals(listOf(RatingBadge.RT_FRESH, RatingBadge.RT_SPILLED), ratings.criticEntries.map { it.badge }) + assertEquals(listOf("8.0"), ratings.communityEntries.map { it.text }) + assertEquals(listOf(RatingBadge.IMDB), ratings.communityEntries.map { it.badge }) + } + + @Test + fun everyRtMarkHasItsOwnBadge() { + val badges = + listOf( + 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 + fun visibleEntriesAreInPwaOrderAndFollowTheDisplaySettings() { + // Rotten Tomatoes (critic rating setting) before IMDb (community rating setting) + 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 + fun tmdbIsTheCommunityEntryWhenItIsTheOnlySource() { + val entries = SourceRatings.of(tmdbScore = 7.6, tmdbVotes = 100).visibleEntries(community = true, critic = true) + assertEquals(listOf(RatingBadge.TMDB), entries.map { it.badge }) + assertEquals(listOf("7.6"), entries.map { it.text }) + } + + @Test + fun noEntriesWithoutRatings() { + assertTrue(SourceRatings().isEmpty) + assertTrue(SourceRatings().visibleEntries(community = true, critic = true).isEmpty()) + assertFalse(movie.isEmpty) + } + + @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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt new file mode 100644 index 00000000..adb5ca33 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt @@ -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() + private val tvApi = mockk() + private val seerrApi = + mockk { + every { moviesApi } returns this@SeerrRatingsItemsTest.moviesApi + every { tvApi } returns this@SeerrRatingsItemsTest.tvApi + } + private val active = MutableStateFlow(true) + private val seerrService = + mockk { + every { api } returns seerrApi + every { this@mockk.active } returns this@SeerrRatingsItemsTest.active + } + private val userLibraryApi = mockk() + private val jellyfin = + mockk { + 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? = 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) = + 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()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt new file mode 100644 index 00000000..c6b008ea --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt @@ -0,0 +1,266 @@ +package com.github.damontecres.wholphin.services + +import com.github.damontecres.wholphin.api.seerr.MoviesApi +import com.github.damontecres.wholphin.api.seerr.SeerrApiClient +import com.github.damontecres.wholphin.api.seerr.TvApi +import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException +import com.github.damontecres.wholphin.api.seerr.infrastructure.Serializer +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200Response +import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingscombinedGet200ResponseImdb +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.RtMark +import com.github.damontecres.wholphin.data.model.SeerrItemType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.api.BaseItemDto +import org.jellyfin.sdk.model.api.BaseItemKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.util.UUID + +class SeerrRatingsServiceTest { + private val moviesApi = mockk() + private val tvApi = mockk() + private val apiClient = + mockk { + every { moviesApi } returns this@SeerrRatingsServiceTest.moviesApi + every { tvApi } returns this@SeerrRatingsServiceTest.tvApi + } + private var activeFlow = flowOf(true) + private val seerrService = + mockk { + every { api } returns apiClient + every { active } answers { activeFlow } + } + private val service = SeerrRatingsService(seerrService, mockk()) + + private fun movieDetails( + average: Double?, + count: Int?, + ) = mockk { + every { voteAverage } returns average?.toBigDecimal() + every { voteCount } returns count + } + + private fun tvDetails( + average: Double?, + count: Int?, + ) = mockk { + every { voteAverage } returns average?.toBigDecimal() + every { voteCount } returns count + } + + private val fullMovieRatings = + MovieMovieIdRatingscombinedGet200Response( + rt = + MovieMovieIdRatingsGet200Response( + criticsScore = 96, + criticsRating = "Certified Fresh", + audienceScore = 90, + audienceRating = "Upright", + ), + imdb = MovieMovieIdRatingscombinedGet200ResponseImdb(criticsScore = 8.8.toBigDecimal(), criticsScoreCount = 2_500_000), + ) + + @Test + fun movieUsesRatingsCombinedAndDoesNotRequestTmdbWhenOtherSourcesExist() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 10) } returns fullMovieRatings + + val ratings = service.get(SeerrItemType.MOVIE, 10)!! + + assertEquals(96, ratings.rtCritics?.percent) + assertEquals(RtMark.FRESH, ratings.rtCritics?.mark) + assertEquals(90, ratings.rtAudience?.percent) + assertEquals(8.8, ratings.imdb!!, 0.0) + assertNull(ratings.tmdb) + coVerify(exactly = 0) { moviesApi.movieMovieIdGet(any()) } + } + + @Test + fun unknownRtLabelsDoNotDropTheResponse() = + runTest { + val json = Serializer.kotlinxSerializationJson + // Labels Seerr might add later must not fail parsing; the scores still show + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 30) } returns + json.decodeFromString( + """{"rt":{"criticsScore":92,"criticsRating":"Mystery","audienceScore":80,"audienceRating":"Whatever"},""" + + """"imdb":{"criticsScore":8.8,"criticsScoreCount":2500000}}""", + ) + coEvery { tvApi.tvTvIdRatingsGet(tvId = 31) } returns + json.decodeFromString( + """{"criticsScore":70,"criticsRating":"Brand New","audienceScore":60,"audienceRating":"Other"}""", + ) + + val movie = service.get(SeerrItemType.MOVIE, 30)!! + assertEquals(92, movie.rtCritics?.percent) + assertEquals(RtMark.UNKNOWN, movie.rtCritics?.mark) + assertEquals(RtMark.UPRIGHT, movie.rtAudience?.mark) + assertEquals(8.8, movie.imdb!!, 0.0) + + val tv = service.get(SeerrItemType.TV, 31)!! + assertEquals(70, tv.rtCritics?.percent) + assertEquals(RtMark.UNKNOWN, tv.rtCritics?.mark) + assertEquals(60, tv.rtAudience?.percent) + } + + @Test + fun seriesUsesTvRatingsIncludingAudienceAndHasNoImdb() = + runTest { + coEvery { tvApi.tvTvIdRatingsGet(tvId = 20) } returns + TvTvIdRatingsGet200Response( + criticsScore = 70, + criticsRating = "Fresh", + audienceScore = 30, + audienceRating = "Spilled", + ) + + val ratings = service.get(SeerrItemType.TV, 20)!! + + assertEquals(RtMark.FRESH, ratings.rtCritics?.mark) + assertEquals(RtMark.SPILLED, ratings.rtAudience?.mark) + assertNull(ratings.imdb) + } + + @Test + fun tmdbIsFetchedOnlyAsFallbackAndZeroVotesAreUnrated() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 1) } returns + MovieMovieIdRatingscombinedGet200Response() + coEvery { moviesApi.movieMovieIdGet(movieId = 1) } returns movieDetails(7.4, 321) + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 2) } returns + MovieMovieIdRatingscombinedGet200Response() + coEvery { moviesApi.movieMovieIdGet(movieId = 2) } returns movieDetails(0.0, 0) + + assertEquals(7.4, service.get(SeerrItemType.MOVIE, 1)!!.tmdb!!, 0.001) + assertTrue(service.get(SeerrItemType.MOVIE, 2)!!.isEmpty) + } + + @Test + fun knownTmdbVoteAvoidsExtraRequest() = + runTest { + coEvery { tvApi.tvTvIdRatingsGet(tvId = 3) } returns TvTvIdRatingsGet200Response() + + val ratings = service.get(SeerrItemType.TV, 3, TmdbVote(8.1, 900))!! + + assertEquals(8.1, ratings.tmdb!!, 0.0) + coVerify(exactly = 0) { tvApi.tvTvIdGet(any()) } + } + + @Test + fun movieAndSeriesWithTheSameTmdbIdDoNotShareCachedRatings() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 603) } returns fullMovieRatings + coEvery { tvApi.tvTvIdRatingsGet(tvId = 603) } returns + TvTvIdRatingsGet200Response(criticsScore = 12, criticsRating = "Rotten") + + val movie = service.get(SeerrItemType.MOVIE, 603)!! + val tv = service.get(SeerrItemType.TV, 603)!! + + assertEquals(96, movie.rtCritics?.percent) + assertEquals(12, tv.rtCritics?.percent) + assertEquals(RtMark.ROTTEN, tv.rtCritics?.mark) + assertEquals(movie, service.cached(SeerrItemType.MOVIE, 603)) + assertEquals(tv, service.cached(SeerrItemType.TV, 603)) + } + + @Test + fun successIsCachedSoSeerrIsAskedOnce() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } returns fullMovieRatings + + service.get(SeerrItemType.MOVIE, 5) + service.get(SeerrItemType.MOVIE, 5) + + coVerify(exactly = 1) { moviesApi.movieMovieIdRatingscombinedGet(movieId = 5) } + } + + @Test + fun failuresAreNotCachedAndAreRetried() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 7) } throws IOException("down") andThen fullMovieRatings + + assertNull(service.get(SeerrItemType.MOVIE, 7)) + assertNull(service.cached(SeerrItemType.MOVIE, 7)) + assertNotNull(service.get(SeerrItemType.MOVIE, 7)?.rtCritics) + } + + @Test + fun serverErrorIsAFailureButNotFoundIsAnAnswer() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 8) } throws ClientException("boom", 500) + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 9) } throws ClientException("none", 404) + coEvery { moviesApi.movieMovieIdGet(movieId = 9) } returns movieDetails(6.0, 40) + + assertNull(service.get(SeerrItemType.MOVIE, 8)) + assertNull(service.cached(SeerrItemType.MOVIE, 8)) + + // 404 from ratings means no RT/IMDb data, TMDB is still available + assertEquals(6.0, service.get(SeerrItemType.MOVIE, 9)!!.tmdb!!, 0.0) + assertNotNull(service.cached(SeerrItemType.MOVIE, 9)) + } + + @Test + fun failedTmdbFallbackIsNotCachedAsNoRating() = + runTest { + coEvery { tvApi.tvTvIdRatingsGet(tvId = 11) } returns TvTvIdRatingsGet200Response() + coEvery { tvApi.tvTvIdGet(tvId = 11) } throws IOException("down") andThen tvDetails(7.0, 10) + + assertNull(service.get(SeerrItemType.TV, 11)) + assertEquals(7.0, service.get(SeerrItemType.TV, 11)!!.tmdb!!, 0.0) + } + + private fun jellyfinItem( + type: BaseItemKind, + providerIds: Map?, + ) = BaseItem(BaseItemDto(id = UUID.randomUUID(), type = type, providerIds = providerIds)) + + @Test + fun jellyfinItemResolvesRatingsThroughItsTmdbId() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(movieId = 42) } returns fullMovieRatings + + val item = jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42")) + assertNull(service.cachedFor(item)) + + val ratings = service.forItem(item).first() + + assertEquals(96, ratings?.rtCritics?.percent) + // a later screen can start from the cached value instead of the Jellyfin values + assertEquals(ratings, service.cachedFor(item)) + assertNull(service.cachedFor(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "42")))) + } + + @Test + fun jellyfinItemFallsBackToNullWhenSeerrCannotAnswer() = + runTest { + coEvery { moviesApi.movieMovieIdRatingscombinedGet(any()) } returns fullMovieRatings + coEvery { tvApi.tvTvIdRatingsGet(any()) } throws IOException("down") + + // no TMDB id + assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, null)).first()) + assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Imdb" to "tt1"))).first()) + // not something that has ratings + assertNull(service.forItem(jellyfinItem(BaseItemKind.BOX_SET, mapOf("Tmdb" to "1"))).first()) + // call fails + assertNull(service.forItem(jellyfinItem(BaseItemKind.SERIES, mapOf("Tmdb" to "2"))).first()) + // Seerr not configured + activeFlow = flowOf(false) + assertNull(service.forItem(jellyfinItem(BaseItemKind.MOVIE, mapOf("Tmdb" to "42"))).first()) + coVerify(exactly = 0) { moviesApi.movieMovieIdRatingscombinedGet(any()) } + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt index 4cae61ba..2e84e2ca 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestUpdateChecker.kt @@ -1,11 +1,16 @@ package com.github.damontecres.wholphin.test +import com.github.damontecres.wholphin.services.UpdateChecker import com.github.damontecres.wholphin.services.getDownloadUrl +import com.github.damontecres.wholphin.util.Version import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert import org.junit.Before import org.junit.Test @@ -19,71 +24,151 @@ class TestUpdateChecker { lateinit var releaseV108Json: JsonObject val assetsV108Json: JsonArray by lazy { releaseV108Json["assets"]!!.jsonArray } - private fun read(filename: String): JsonObject { + private val forgejoLatest: JsonObject by lazy { read("forgejo_release_latest.json") } + private val forgejoAssets: JsonArray by lazy { forgejoLatest["assets"]!!.jsonArray } + private val forgejoReleases: JsonArray by lazy { readElement("forgejo_releases.json").jsonArray } + private val suffixedAssets: JsonArray by lazy { read("forgejo_release_suffixed.json")["assets"]!!.jsonArray } + + private fun readElement(filename: String): JsonElement { val resource = javaClass.classLoader?.getResource(filename) Assert.assertNotNull(resource) val fileContents = Paths.get(resource!!.toURI()).readText() - return Json.parseToJsonElement(fileContents).jsonObject + return Json.parseToJsonElement(fileContents) } + private fun read(filename: String): JsonObject = readElement(filename).jsonObject + + private fun forgejoDownload(name: String) = + "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/$name" + @Before fun setup() { releaseJson = read("release_develop.json") } + // The GitHub fixtures are upstream Wholphin releases: their assets are named Wholphin*.apk, which + // the Wolfskin updater must not select. + @Test - fun `Release chooses release`() { + fun `Release does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, false, listOf()) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + Assert.assertNull(url) } @Test - fun `Choose abi`() { + fun `Choose abi does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk", url) + Assert.assertNull(url) } @Test - fun `Choose unknown abi`() { + fun `Choose unknown abi does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, false, listOf("unknown")) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) + Assert.assertNull(url) } @Test - fun `Debug chooses debug`() { + fun `Debug does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, true, listOf()) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk", url) + Assert.assertNull(url) } @Test - fun `Choose debug abi`() { + fun `Choose debug abi does not choose upstream Wholphin assets`() { val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) - Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk", url) + Assert.assertNull(url) } @Test - fun `Test regular release`() { + fun `Test regular release does not choose upstream Wholphin assets`() { releaseV108Json = read("release_v108.json") - getDownloadUrl(assetsV108Json, false, listOf("arm64-v8a")).let { url -> - Assert.assertEquals( - "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-arm64-v8a.apk", - url, - ) + Assert.assertNull(getDownloadUrl(assetsV108Json, false, listOf("arm64-v8a"))) + Assert.assertNull(getDownloadUrl(assetsV108Json, false, listOf("armeabi-v7a"))) + Assert.assertNull(getDownloadUrl(assetsV108Json, false, emptyList())) + Assert.assertNull(getDownloadUrl(assetsV108Json, true, listOf("arm64-v8a"))) + } + + @Test + fun `Parse Forgejo latest release`() { + val release = UpdateChecker.parseRelease(forgejoLatest, debug = false, supportedABIs = listOf("arm64-v8a")) + checkNotNull(release) + Assert.assertEquals(Version.fromString("v1.0.9-2-g3094fc8c"), release.version) + Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), release.downloadUrl) + Assert.assertEquals("2026-10-20T09:05:00Z", release.publishedAt) + Assert.assertEquals(listOf("Subtitle preference fix"), release.notes) + } + + @Test + fun `Choose Wolfskin asset for abi`() { + Assert.assertEquals(forgejoDownload("Wolfskin-arm64-v8a.apk"), getDownloadUrl(forgejoAssets, false, listOf("arm64-v8a"))) + Assert.assertEquals(forgejoDownload("Wolfskin-armeabi-v7a.apk"), getDownloadUrl(forgejoAssets, false, listOf("armeabi-v7a"))) + } + + @Test + fun `Choose universal Wolfskin asset for unknown abi`() { + Assert.assertEquals(forgejoDownload("Wolfskin.apk"), getDownloadUrl(forgejoAssets, false, listOf("unknown"))) + Assert.assertEquals(forgejoDownload("Wolfskin.apk"), getDownloadUrl(forgejoAssets, false, emptyList())) + } + + @Test + fun `Debug asset is not offered when release has none`() { + Assert.assertNull(getDownloadUrl(forgejoAssets, true, listOf("arm64-v8a"))) + } + + @Test + fun `Choose debug ABI before universal and fall back for missing ABI`() { + for ((abis, name) in listOf( + listOf("arm64-v8a") to "Wolfskin-debug-arm64-v8a.apk", + listOf("unknown") to "Wolfskin-debug.apk", + emptyList() to "Wolfskin-debug.apk", + )) { + Assert.assertEquals(forgejoDownload(name), getDownloadUrl(suffixedAssets, true, abis)) } - getDownloadUrl(assetsV108Json, false, listOf("armeabi-v7a")).let { url -> - Assert.assertEquals( - "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-armeabi-v7a.apk", - url, - ) - } - getDownloadUrl(assetsV108Json, false, emptyList()).let { url -> - Assert.assertEquals( - "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin.apk", - url, - ) - } - getDownloadUrl(assetsV108Json, true, listOf("arm64-v8a")).let { url -> - Assert.assertNull(url) + } + + @Test + fun `Choose release suffixed ABI before universal and unsuffixed assets`() { + val assets = JsonArray(forgejoAssets + suffixedAssets) + for ((abis, name) in listOf( + listOf("arm64-v8a") to "Wolfskin-release-arm64-v8a.apk", + listOf("armeabi-v7a") to "Wolfskin-release.apk", + emptyList() to "Wolfskin-release.apk", + )) { + Assert.assertEquals(forgejoDownload(name), getDownloadUrl(assets, false, abis)) } } + + @Test + fun `Release and debug assets do not cross build types`() { + val debugAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-debug") }) + val releaseAssets = JsonArray(suffixedAssets.filter { it.jsonObject["name"]!!.jsonPrimitive.content.contains("-release") }) + Assert.assertNull(getDownloadUrl(debugAssets, false, listOf("arm64-v8a"))) + Assert.assertNull(getDownloadUrl(releaseAssets, true, listOf("arm64-v8a"))) + } + + @Test + fun `Find installed version in release list`() { + val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8c")) + Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content) + } + + @Test + fun `Find release with different hash abbreviation length`() { + val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-2-g3094fc8")) + Assert.assertEquals("v1.0.9-2-g3094fc8c", release?.get("name")?.jsonPrimitive?.content) + } + + @Test + fun `Find release with absent or zero commit count`() { + val release = UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.10")) + Assert.assertEquals("v1.0.10-0-gfeedbee", release?.get("name")?.jsonPrimitive?.content) + val plainRelease = JsonObject(forgejoReleases.first().jsonObject + ("name" to JsonPrimitive("v1.0.10"))) + val plainReleases = JsonArray(listOf(plainRelease)) + Assert.assertEquals(plainReleases.first(), UpdateChecker.findReleaseByVersion(plainReleases, Version.fromString("1.0.10-0-gfeedbee"))) + } + + @Test + fun `Find no release for version missing from list`() { + Assert.assertNull(UpdateChecker.findReleaseByVersion(forgejoReleases, Version.fromString("1.0.9-3-gffffff"))) + } } diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt new file mode 100644 index 00000000..3ef4ad20 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt @@ -0,0 +1,44 @@ +package com.github.damontecres.wholphin.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.github.damontecres.wholphin.ui.theme.colors.WolfskinThemeColors +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Normal and focused TV text must remain readable on the fork's dark palette. */ +class WolfskinThemeColorsTest { + @Test + fun darkPaletteHasReadableNormalAndFocusedText() { + val television = WolfskinThemeColors.darkScheme + val material = WolfskinThemeColors.darkSchemeMaterial + val textAndBackgrounds = + listOf( + television.onBackground to television.background, + television.onSurface to television.surface, + television.onSurfaceVariant to television.surfaceVariant, + television.onPrimary to television.primary, + television.onPrimaryContainer to television.primaryContainer, + television.onSecondary to television.secondary, + television.onSecondaryContainer to television.secondaryContainer, + television.onTertiary to television.tertiary, + television.onTertiaryContainer to television.tertiaryContainer, + material.onBackground to material.background, + material.onSurface to material.surface, + material.onPrimary to material.primary, + material.onPrimaryContainer to material.primaryContainer, + ) + textAndBackgrounds.forEach { (text, background) -> + assertReadable(text, background) + } + } + + private fun assertReadable(text: Color, background: Color) { + val textLuminance = text.luminance() + val backgroundLuminance = background.luminance() + val contrast = + (maxOf(textLuminance, backgroundLuminance) + .05f) / + (minOf(textLuminance, backgroundLuminance) + .05f) + assertTrue("Text contrast $contrast is below 4.5:1", contrast >= 4.5f) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt new file mode 100644 index 00000000..4fb3dc5c --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt @@ -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() + private val tvApi = mockk() + private val seerrApi = + mockk { + every { moviesApi } returns this@SourceRatingsRowTest.moviesApi + every { tvApi } returns this@SourceRatingsRowTest.tvApi + } + private val seerrService = + mockk { + every { api } returns seerrApi + every { active } returns flowOf(true) + } + private val userLibraryApi = mockk() + private val jellyfin = mockk { 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 = 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) + } +} diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt index 7189adb1..87e5670a 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/playback/PlaybackViewModelTests.kt @@ -292,6 +292,49 @@ class PlaybackViewModelTests { return viewModel } + @Test + fun `Direct play includes the opened live stream ID`() = + runTest(testDispatcher) { + setupPreferences { cinemaMode = false } + val liveStreamId = "opened-live-stream" + val liveSource = mediaSource.copy(liveStreamId = liveStreamId) + coEvery { mockUserLibraryApi.getItem(movie.id) } returns successResponse(movie) + coEvery { mockMediaInfoApi.getPostedPlaybackInfo(any(), any()) } returns + successResponse( + PlaybackInfoResponse( + mediaSources = listOf(liveSource), + playSessionId = playSessionId, + ), + ) + every { + mockVideosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = liveSource.id, + liveStreamId = liveSource.liveStreamId, + static = true, + tag = liveSource.eTag, + playSessionId = playSessionId, + ) + } returns videoStreamUrl + + createViewModel(Destination.Playback(movie.id, 0L)) + + val mediaItem = slot() + verify(exactly = 1) { mockPlayer.setMediaItem(capture(mediaItem), any()) } + val playbackUri = mediaItem.captured.localConfiguration?.uri + Assert.assertEquals(videoStreamUrl, playbackUri.toString()) + verify(exactly = 1) { + mockVideosApi.getVideoStreamUrl( + itemId = movie.id, + mediaSourceId = liveSource.id, + liveStreamId = liveStreamId, + static = true, + tag = liveSource.eTag, + playSessionId = playSessionId, + ) + } + } + @Test fun `Play intro first`() = runTest(testDispatcher) { diff --git a/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt b/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt new file mode 100644 index 00000000..a6a23244 --- /dev/null +++ b/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt @@ -0,0 +1,87 @@ +package com.github.damontecres.wholphin.ui.setup + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.pressKey +import com.github.damontecres.wholphin.data.model.JellyfinUser +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +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.UUID + +@Config(sdk = [34]) +@RunWith(RobolectricTestRunner::class) +class UserListFocusTest { + @get:Rule val composeTestRule = createComposeRule() + + private val serverId = UUID.fromString("00000000-0000-0000-0000-000000000010") + private val users = + listOf("Ada", "Bert", "Cleo").mapIndexed { index, name -> + JellyfinUserAndImage( + user = + JellyfinUser( + id = UUID(0, (index + 1).toLong()), + name = name, + serverId = serverId, + accessToken = "token", + ), + imageUrl = null, + known = true, + ) + } + + private fun showUsers(currentUser: JellyfinUser?) { + composeTestRule.setContent { + WholphinTheme { + UserList( + users = users, + currentUser = currentUser, + onSwitchUser = {}, + onAddUser = {}, + onRemoveUser = {}, + onSwitchServer = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + @Test + fun `opening selector focuses active profile and supports D-pad navigation`() { + showUsers(users[1].user) + + composeTestRule.onNodeWithText("B").assertIsFocused().performKeyInput { + pressKey(Key.DirectionRight) + } + composeTestRule.onNodeWithText("C").assertIsFocused() + } + + @Test + fun `opening selector scrolls to an active profile outside the initial viewport`() { + showUsers(users[2].user) + + composeTestRule.onNodeWithText("C").assertIsFocused() + } + + @Test + fun `opening selector without a listed active profile focuses first profile`() { + showUsers(null) + + composeTestRule.onNodeWithText("A").assertIsFocused() + } + + @Test + fun `opening selector with a removed active profile focuses first profile`() { + showUsers(users[1].user.copy(id = UUID(0, 99))) + + composeTestRule.onNodeWithText("A").assertIsFocused() + } +} diff --git a/app/src/test/resources/forgejo_release_latest.json b/app/src/test/resources/forgejo_release_latest.json new file mode 100644 index 00000000..f99734b9 --- /dev/null +++ b/app/src/test/resources/forgejo_release_latest.json @@ -0,0 +1,44 @@ +{ + "id": 42, + "name": "v1.0.9-2-g3094fc8c", + "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", + "body": "Wolfskin build\n\n**Full Changelog**: https://git.justinvisser.org/justinzeus/wolfskin/compare/v1.0.8...v1.0.9", + "draft": false, + "prerelease": false, + "created_at": "2026-10-20T09:00:00Z", + "published_at": "2026-10-20T09:05:00Z", + "target_commitish": "3094fc8c", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c", + "assets": [ + { + "id": 101, + "name": "Wolfskin.apk", + "size": 12345678, + "download_count": 3, + "created_at": "2026-10-20T09:05:00Z", + "uuid": "11111111-1111-1111-1111-111111111111", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin.apk" + }, + { + "id": 102, + "name": "Wolfskin-arm64-v8a.apk", + "size": 7654321, + "download_count": 1, + "created_at": "2026-10-20T09:05:00Z", + "uuid": "22222222-2222-2222-2222-222222222222", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-arm64-v8a.apk" + }, + { + "id": 103, + "name": "Wolfskin-armeabi-v7a.apk", + "size": 6543210, + "download_count": 0, + "created_at": "2026-10-20T09:05:00Z", + "uuid": "33333333-3333-3333-3333-333333333333", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-armeabi-v7a.apk" + } + ] +} diff --git a/app/src/test/resources/forgejo_release_suffixed.json b/app/src/test/resources/forgejo_release_suffixed.json new file mode 100644 index 00000000..c2155081 --- /dev/null +++ b/app/src/test/resources/forgejo_release_suffixed.json @@ -0,0 +1,24 @@ +{ + "name": "v1.0.9-2-g3094fc8c", + "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", + "published_at": "2026-10-20T09:05:00Z", + "body": "Wolfskin release and debug builds", + "assets": [ + { + "name": "Wolfskin-debug.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug.apk" + }, + { + "name": "Wolfskin-release.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release.apk" + }, + { + "name": "Wolfskin-debug-arm64-v8a.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-debug-arm64-v8a.apk" + }, + { + "name": "Wolfskin-release-arm64-v8a.apk", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.9-2-g3094fc8c/Wolfskin-release-arm64-v8a.apk" + } + ] +} diff --git a/app/src/test/resources/forgejo_releases.json b/app/src/test/resources/forgejo_releases.json new file mode 100644 index 00000000..ab84e411 --- /dev/null +++ b/app/src/test/resources/forgejo_releases.json @@ -0,0 +1,52 @@ +[ + { + "id": 44, + "name": "v1.0.10-0-gfeedbee", + "tag_name": "pwolfskin-v1.0.10-0-gfeedbee", + "body": "", + "draft": false, + "prerelease": false, + "created_at": "2026-10-22T08:00:00Z", + "published_at": "2026-10-22T08:05:00Z", + "target_commitish": "feedbee", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.10-0-gfeedbee", + "assets": [ + { + "id": 104, + "name": "Wolfskin.apk", + "size": 12345679, + "download_count": 0, + "created_at": "2026-10-22T08:05:00Z", + "uuid": "44444444-4444-4444-4444-444444444444", + "type": "attachment", + "browser_download_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/download/pwolfskin-v1.0.10-0-gfeedbee/Wolfskin.apk" + } + ] + }, + { + "id": 42, + "name": "v1.0.9-2-g3094fc8c", + "tag_name": "pwolfskin-v1.0.9-2-g3094fc8c", + "body": "Wolfskin build", + "draft": false, + "prerelease": false, + "created_at": "2026-10-20T09:00:00Z", + "published_at": "2026-10-20T09:05:00Z", + "target_commitish": "3094fc8c", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/pwolfskin-v1.0.9-2-g3094fc8c", + "assets": [] + }, + { + "id": 40, + "name": "nightly", + "tag_name": "nightly", + "body": "", + "draft": false, + "prerelease": true, + "created_at": "2026-10-10T09:00:00Z", + "published_at": "2026-10-10T09:05:00Z", + "target_commitish": "main", + "html_url": "https://git.justinvisser.org/justinzeus/wolfskin/releases/tag/nightly", + "assets": [] + } +] diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..ce7d039f --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,24 @@ +FROM eclipse-temurin:21.0.11_10-jdk-jammy@sha256:dbfd085220ae632a0830166e443747d1ee89e9038d92e3b48c3e5e9d8292b9a7 + +RUN apt-get update && apt-get install -y --no-install-recommends git unzip \ + && rm -rf /var/lib/apt/lists/* +ENV ANDROID_HOME=/opt/android-sdk ANDROID_SDK_ROOT=/opt/android-sdk +ENV PATH="/opt/android-sdk/cmdline-tools/23.0/bin:/opt/android-sdk/build-tools/36.0.0:${PATH}" +RUN curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-16111833_latest.zip -o /tmp/tools.zip \ + && echo 'e025545c62a8e64c7559119566a569fb1dec5f60 /tmp/tools.zip' | sha1sum -c - \ + && mkdir -p "$ANDROID_HOME/cmdline-tools" \ + && unzip -q /tmp/tools.zip -d /tmp/android-tools \ + && mv /tmp/android-tools/cmdline-tools "$ANDROID_HOME/cmdline-tools/23.0" \ + && rm -rf /tmp/tools.zip /tmp/android-tools \ + && yes | sdkmanager --licenses >/dev/null \ + && sdkmanager 'platforms;android-37.0' 'build-tools;36.0.0' \ + && while IFS='=' read -r name value; do \ + if [ "$name" = Pkg.Revision ]; then test "$value" = 2 || exit 1; fi; \ + done < "$ANDROID_HOME/platforms/android-37.0/source.properties" \ + && curl -fsSL https://dl.google.com/android/repository/platform-tools_r37.0.1-linux.zip -o /tmp/platform-tools.zip \ + && echo '477254aa5f903c15cf51001717bdf347fb6b53e0 /tmp/platform-tools.zip' | sha1sum -c - \ + && unzip -q /tmp/platform-tools.zip -d "$ANDROID_HOME" \ + && rm /tmp/platform-tools.zip \ + && mkdir -m 1777 /gradle +ENV GRADLE_USER_HOME=/gradle HOME=/tmp +COPY signing.init.gradle /opt/wolfskin/signing.init.gradle diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..394aa9cd --- /dev/null +++ b/docker/README.md @@ -0,0 +1,136 @@ +# Wolfskin TV container builds + +Run from a full Git checkout with the upstream `v*` and `p*` tags available. The +host needs Docker, GNU Make, Bash, Git, and `flock` (util-linux), not Java or the +Android SDK. + +```sh +git fetch origin --tags +make apk # clean, signed default release: three ABI splits + universal +make test # upstream testDefaultDebugUnitTest in the same image, no secrets +make apk-debug # default debug, no secrets; MPV stub and no extra codec decoders +``` + +APKs remain under `app/build/outputs/apk/default/{release,debug}/`. Debug builds +are not playback-complete and must not be distributed as releases. Upstream's +`.github/workflows` and dependency version catalog are unchanged; release builds +use the catalog's exact Wholphin extension version (currently `0.2.4`). Do not +place local extension AARs in `app/libs`: upstream prefers them over the pinned +GitHub Packages dependencies. + +Upload release assets renamed to `Wolfskin.apk` / `Wolfskin-.apk` for the +in-app updater. Gradle outputs +`Wholphin-default-release--[-].apk`; strip the +flavor, version, and code as well as replacing the prefix. + +Upstream enables JVM unit tests only for debug variants; there is no +`testDefaultReleaseUnitTest` task. `make test` uses the closest default-flavor +task without changing upstream's test configuration. + +## Upstream v1.1.0 integration + +The release merge brings Kotlin `2.4.21`, Gradle `9.8.2`, AGP `9.5.0`, and +ExoplayerHdrUtils `0.4.1`. The pinned SDK/JDK image and serialized Gradle wrapper +remain compatible without changes; native Wholphin extensions remain `0.2.4`. +There are no committed dependency lockfiles or Gradle verification metadata. +Forgejo Actions stays disabled; upstream GitHub workflows remain dormant. + +The app database upgrades from Room schema 35 through 40 to 41 on first launch +after installation. The upstream migrations add series track choices and a home +settings source preference. Downgrading with the upgraded database is not +supported: a TV installation is a one-way step and requires explicit approval. +The optional companion plugin can supply Discover credentials before locally +saved Seerr profiles; do not configure an administrator API key in place of the +household's per-profile Jellyfin login, which Conductarr uses for request routing. + +Build only after committing a release merge: version names come from Git HEAD, +not the working tree. An uncommitted merge still builds with the old version name. +Neither an upstream merge nor its validation build authorizes a release or TV +installation. + +## Toolchain and disk use + +`Dockerfile` pins Temurin JDK `21.0.11+10` by image digest, command-line tools +`23.0` and platform-tools `37.0.1` by archive and checksum, and build-tools +`36.0.0` by SDK package version. The SDK manager registers platform `37.0` and +its licenses; the build rejects any platform revision other than 2. An SDK +revision upgrade therefore requires a deliberate Dockerfile update. +Gradle comes from the committed wrapper. The image is built for `linux/amd64`; +the default local image tag is `wolfskin-build:sdk37-jdk21`. +Upstream's build timestamp means APKs are not byte-for-byte reproducible. + +Gradle downloads and caches live in the named volume `wolfskin-gradle`, not +`/tmp`. Module outputs stay in the checkout. Builds run with the host UID/GID, +`--no-daemon`, and at most four workers. The repository's Git metadata is mounted +read-only, including when the checkout is a linked worktree. + +Override `WOLFSKIN_BUILD_IMAGE` to change the locally built image tag and +`WOLFSKIN_GRADLE_VOLUME` to isolate a cache. `make build-image` builds only the +image. Do not delete shared caches during another build. + +Every Gradle container holds a host-side exclusive lock for its entire run. +Concurrent targets sharing a cache queue rather than timing out on Gradle's +cross-container cache journal lock; a waiting invocation prints one line. +The default lock is `/var/tmp/${WOLFSKIN_GRADLE_VOLUME}.gradle.lock` (using +`wolfskin-gradle` when unset). Override `WOLFSKIN_GRADLE_LOCK_FILE` with a path +whose parent directory already exists. All callers sharing a volume must use +the same lock path on the Docker host. Do not delete the file while builds are +running or waiting. The lock releases automatically when the invocation exits. + +## Release credentials + +The default escrow is +`/opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin/`. Check +`mountpoint -q /mnt/ventoy` before using that path. Set `WOLFSKIN_SECRETS_DIR` +to use another existing directory. Its required files are: + +| File | Contents | +| --- | --- | +| `release.jks` | Separate Wolfskin PKCS12 keystore, RSA 4096, alias `wolfskin` | +| `release.properties` | Java properties `storePassword` and `keyPassword` | +| `github-packages-token` | GitHub token with `read:packages`, username `JustinZeus` | + +The directory must be mode `0700`, each file mode `0600`, owned by the building +user. Never commit them or put their contents in logs. Back up the keystore and +passwords together: losing the release key forces a reinstall. Do not reuse +wolfskin-android's key or the gh CLI login token. + +Release builds mount escrow read-only. The token is loaded inside the container +into upstream's `ORG_GRADLE_PROJECT_WholphinExtensionsUsername` and +`ORG_GRADLE_PROJECT_WholphinExtensionsPassword` environment hooks, never a Docker +command argument. The signing init script registers `wolfskin`; the nonsecret +`release-signing.properties` is mounted as `local.properties` so upstream's +`release.signing.config` hook selects it. Any host `local.properties` is hidden +only for the duration of this container. Keystore and passwords stay outside the +checkout and image. Missing credentials fail the release rather than silently +shipping the MPV stub. + +## Versioning and local release markers + +Upstream computes `versionName` from +`git describe --tags --long --match='v*'`, dropping the leading `v`, and computes +`versionCode` from the count of all local `v*` and `p*` tags. Preserve and fetch +those tags; a shallow or incomplete checkout can give an incorrect version. +Use the full `git describe` output as the human release name. + +After committing the intended release, with a clean checkout and fetched tags: + +```sh +make release-tag +make apk +``` + +The helper creates one lightweight `pwolfskin-YYYY_MM_DD` tag in UTC, appending +`_1`, `_2`, etc. on collision. Each marker adds one to `versionCode` without +changing upstream's `v*`-based version name. It never pushes tags, publishes a +release, or installs an APK. Do not delete published release markers. Tag +creation and publication must be serialized between release operators. + +## Inspecting release artifacts + +Use `apksigner verify --verbose --print-certs APK` and +`aapt2 dump badging APK` inside the build image. Compare the signer SHA-256 with +`keytool -list -v -alias wolfskin -keystore /path/to/release.jks` (enter its +password privately). Inspect APK ZIP entries for real native MPV and ffmpeg +libraries for each shipped ABI; a successful Java compilation alone does not +prove that codec extensions are included. diff --git a/docker/gradle.sh b/docker/gradle.sh new file mode 100755 index 00000000..b5624cbf --- /dev/null +++ b/docker/gradle.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Run the upstream wrapper with a persistent cache and read-only build credentials. +set -euo pipefail +mode=$1 +shift +root=$(git rev-parse --show-toplevel) +git_common=$(git rev-parse --path-format=absolute --git-common-dir) +image=${WOLFSKIN_BUILD_IMAGE:-wolfskin-build:sdk37-jdk21} +cache=${WOLFSKIN_GRADLE_VOLUME:-wolfskin-gradle} +secrets=${WOLFSKIN_SECRETS_DIR:-/opt/stacks/ventoy/machine-parity/manifest-tank/secrets/wolfskin} +lock=${WOLFSKIN_GRADLE_LOCK_FILE:-/var/tmp/${cache}.gradle.lock} +exec {lock_fd}>"$lock" +if ! flock --exclusive --nonblock "$lock_fd"; then + printf 'Waiting for Gradle cache lock: %s\n' "$lock" >&2 + flock --exclusive "$lock_fd" +fi +arguments=(--rm --platform linux/amd64 --user "$(id -u):$(id -g)" + --mount "type=bind,src=$root,dst=$root" --workdir "$root" + --mount "type=volume,src=$cache,dst=/gradle" + --mount "type=bind,src=$git_common,dst=$git_common,readonly") +gradle_arguments=(--no-daemon --console=plain --max-workers=4) +if [[ $mode == release ]]; then + for name in release.jks release.properties github-packages-token; do + if [[ ! -s $secrets/$name ]]; then + printf 'Missing build credential: %s/%s\n' "$secrets" "$name" >&2 + exit 1 + fi + done + arguments+=(--mount "type=bind,src=$secrets,dst=/run/wolfskin,readonly" + --mount "type=bind,src=$root/docker/release-signing.properties,dst=$root/local.properties,readonly") + gradle_arguments+=(--init-script /opt/wolfskin/signing.init.gradle) + docker run "${arguments[@]}" "$image" bash -euc ' + export ORG_GRADLE_PROJECT_WholphinExtensionsUsername=JustinZeus + export ORG_GRADLE_PROJECT_WholphinExtensionsPassword=$(cat /run/wolfskin/github-packages-token) + exec ./gradlew "$@" + ' bash "${gradle_arguments[@]}" "$@" +else + docker run "${arguments[@]}" "$image" ./gradlew "${gradle_arguments[@]}" "$@" +fi diff --git a/docker/release-signing.properties b/docker/release-signing.properties new file mode 100644 index 00000000..7e15afe4 --- /dev/null +++ b/docker/release-signing.properties @@ -0,0 +1 @@ +release.signing.config=wolfskin diff --git a/docker/release-tag.sh b/docker/release-tag.sh new file mode 100755 index 00000000..14b2637c --- /dev/null +++ b/docker/release-tag.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Add one lightweight release marker without publishing it. +set -euo pipefail +if [[ -n $(git status --porcelain) ]]; then + printf 'Commit or discard checkout changes before tagging a release.\n' >&2 + exit 1 +fi +base="pwolfskin-$(date -u +%Y_%m_%d)" +tag=$base +sequence=1 +while git show-ref --verify --quiet "refs/tags/$tag"; do + tag="${base}_${sequence}" + sequence=$((sequence + 1)) +done +git tag "$tag" +printf 'Created %s locally; no tag was pushed.\n' "$tag" +printf 'versionName: %s\n' "$(git describe --tags --long --match='v*' | cut -c 2-)" +printf 'versionCode: %s\n' "$(git tag --list 'v*' 'p*' | wc -l)" diff --git a/docker/signing.init.gradle b/docker/signing.init.gradle new file mode 100644 index 00000000..b07a4770 --- /dev/null +++ b/docker/signing.init.gradle @@ -0,0 +1,17 @@ +// Keep signing material outside the checkout; upstream selects this configuration via local.properties. +allprojects { + pluginManager.withPlugin('com.android.application') { + def passwords = new Properties() + file('/run/wolfskin/release.properties').withInputStream { passwords.load(it) } + android.signingConfigs.create('wolfskin') { + storeFile = file('/run/wolfskin/release.jks') + storePassword = passwords.getProperty('storePassword') + keyPassword = passwords.getProperty('keyPassword') + keyAlias = 'wolfskin' + enableV1Signing = true + enableV2Signing = true + enableV3Signing = true + enableV4Signing = true + } + } +}