diff --git a/Intents.md b/Intents.md index 7e358955..7f01a501 100644 --- a/Intents.md +++ b/Intents.md @@ -33,7 +33,7 @@ Full format ```bash adb shell am start \ -a android.intent.action.SEARCH \ - -n 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv/com.github.damontecres.wholphin.MainActivity' \ + -n '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 'org.justinvisser.wolfskin.tv.debug/com.github.damontecres.wholphin.MainActivity' \ + -n 'com.github.damontecres.wholphin.debug/com.github.damontecres.wholphin.MainActivity' \ --es itemId "5cf8f8e7-2a5f-4aa9-8c12-ddf63d42ee6d" ``` diff --git a/Makefile b/Makefile deleted file mode 100644 index df6bd824..00000000 --- a/Makefile +++ /dev/null @@ -1,21 +0,0 @@ -.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 b8f04e12..ed82692f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,3 @@ -# 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 96883e40..8e8b259f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,7 @@ configure { compileSdk = libs.versions.compileSdk.getInt() defaultConfig { - applicationId = "org.justinvisser.wolfskin.tv" + applicationId = "com.github.damontecres.wholphin" 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 cb6c15c6..606329c9 100644 --- a/app/src/debug/res/values/strings.xml +++ b/app/src/debug/res/values/strings.xml @@ -1,4 +1,4 @@ - Wolfskin (Debug) + Wholphin (Debug) diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index dfc380e1..87623e5e 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 a1410d0b..c9eb61eb 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/MainActivity.kt @@ -46,7 +46,6 @@ 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 @@ -60,13 +59,12 @@ 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.WolfskinThemeColors +import com.github.damontecres.wholphin.ui.theme.colors.PurpleThemeColors import com.github.damontecres.wholphin.ui.util.ProvideLocalClock import com.github.damontecres.wholphin.util.DebugLogTree import com.github.damontecres.wholphin.util.ExceptionHandler @@ -120,9 +118,6 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var imageUrlService: ImageUrlService - @Inject - lateinit var seerrRatingsService: SeerrRatingsService - @Inject lateinit var refreshRateService: RefreshRateService @@ -234,7 +229,7 @@ class MainActivity : AppCompatActivity() { viewModel.appStart(intent) setContent { - MaterialTheme(colorScheme = WolfskinThemeColors.darkScheme) { + MaterialTheme(colorScheme = PurpleThemeColors.darkScheme) { Surface(Modifier.fillMaxSize()) { val userPreferences by userPreferencesService.flow.collectAsState(null) if (userPreferences == null) { @@ -267,10 +262,7 @@ class MainActivity : AppCompatActivity() { LaunchedEffect(appPreferences.debugLogging) { DebugLogTree.INSTANCE.enabled = appPreferences.debugLogging } - CompositionLocalProvider( - LocalImageUrlService provides imageUrlService, - LocalSeerrRatingsService provides seerrRatingsService, - ) { + CompositionLocalProvider(LocalImageUrlService provides imageUrlService) { 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 deleted file mode 100644 index aa023e97..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/WolfskinDefaults.kt +++ /dev/null @@ -1,9 +0,0 @@ -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 5139fb73..b46a5f33 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 saved/retrieved from a [MostRecentServerProvider] + * This can be read/written to shared preferences */ sealed interface MostRecentServer { /** @@ -58,12 +58,12 @@ sealed interface MostRecentServer { interface MostRecentServerProvider { /** - * Get the [MostRecentServer] from this provider + * Get the [MostRecentServer] from the shared preferences in [context] */ fun get(): MostRecentServer /** - * Save the [CurrentUser] as the most recent server + * Save the [CurrentUser] as the most recent server into the shared preferences in [context] */ suspend fun save(current: CurrentUser) @@ -86,6 +86,9 @@ 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) @@ -100,6 +103,9 @@ 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 256ccd65..32e588d7 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,5 +1,6 @@ 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 @@ -31,6 +32,7 @@ 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 @@ -233,6 +235,26 @@ 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") + } + } + }, ), ) @@ -348,6 +370,8 @@ 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 fe4650a6..24d7d340 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,6 +3,8 @@ 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 @@ -121,3 +123,20 @@ 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 deleted file mode 100644 index 581a4aa2..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/SourceRatings.kt +++ /dev/null @@ -1,231 +0,0 @@ -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 63761d99..68653272 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.WOLFSKIN, + defaultValue = AppThemeColors.PURPLE, 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://git.justinvisser.org/api/v1/repos/justinzeus/wolfskin/releases/latest", + defaultValue = "https://api.github.com/repos/damontecres/Wholphin/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 dbaa219e..4ce6583f 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.HomeSlimItemFields +import com.github.damontecres.wholphin.ui.SlimItemFields 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 = HomeSlimItemFields, + fields = SlimItemFields, 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 = HomeSlimItemFields, + fields = SlimItemFields, 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 deleted file mode 100644 index 08d31a9d..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrRatingsService.kt +++ /dev/null @@ -1,411 +0,0 @@ -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 706285f2..a00de8e4 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 = "Wolfskin" + const val ASSET_NAME = "Wholphin" const val APK_NAME = "$ASSET_NAME.apk" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" @@ -65,53 +65,6 @@ 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) - } } /** @@ -174,29 +127,17 @@ 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 request = - Request - .Builder() - .url(RELEASES_URL) - .get() - .build() + val url = + "https://api.github.com/repos/damontecres/Wholphin/releases/tags/v${version.major}.${version.minor}.${version.patch}" return withContext(WholphinDispatchers.IO) { - 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 - } - } + val request = + Request + .Builder() + .url(url) + .get() + .build() + getRelease(request) } } @@ -217,11 +158,36 @@ class UpdateChecker private fun getRelease(request: Request): Release? { return okHttpClient.newCall(request).execute().use { if (it.isSuccessful) { - parseRelease(Json.parseToJsonElement(it.body.string()).jsonObject, BuildConfig.DEBUG) + 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") + } } 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 a284ef0b..c7cbaf44 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,6 +4,7 @@ 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 @@ -241,12 +242,30 @@ val MediaSegmentType.skipStringRes: Int fun AnnotatedString.Builder.dot() = append(" \u2022 ") -fun listToDotString(strings: List): AnnotatedString = +fun listToDotString( + strings: List, + communityRating: Float?, + criticRating: Float?, +): 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 786db3d5..2e57b407 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,22 +75,12 @@ 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 17649be9..ce8952a7 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,7 +41,6 @@ fun WatchedIconBackground(): Color = when (LocalTheme.current) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -56,8 +55,6 @@ 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 57ff8957..d2661ae4 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, null) + QuickDetails(item.ui.quickDetails, 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 902ce814..6db89c75 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,24 +1,30 @@ 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 @@ -26,12 +32,6 @@ 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,16 +39,21 @@ 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) + QuickDetailsText(details.basic, Modifier, textStyle, inlineContentMap) if (DisplayToggle.OFFICIAL_RATING in enabled) { - QuickDetailsText(details.officialRating, Modifier, textStyle) + 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) } - SourceRatingsRow(ratings, Modifier, textStyle) } if (timeRemaining != null) { TimeRemaining(timeRemaining, textStyle = textStyle) @@ -58,41 +63,76 @@ 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 460eda05..c52fb52f 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,29 +1,93 @@ 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) -/** - * 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. - */ +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, + ) +} + @Composable fun SimpleStarRating( text: String?, @@ -51,3 +115,206 @@ 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 f2addcd8..9021e213 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,7 +143,6 @@ fun sliderActiveColor(focused: Boolean): Color { return when (theme) { AppThemeColors.UNRECOGNIZED, AppThemeColors.PURPLE, - AppThemeColors.WOLFSKIN, AppThemeColors.BLUE, AppThemeColors.GREEN, AppThemeColors.ORANGE, @@ -180,7 +179,6 @@ 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 deleted file mode 100644 index 19567883..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRow.kt +++ /dev/null @@ -1,152 +0,0 @@ -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 d0017d84..612c35a0 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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: SourceRatings?, + rating: DiscoverRating?, 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 34bf4657..1c0915ec 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,15 +20,14 @@ 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.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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.QuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.formatDuration import com.github.damontecres.wholphin.ui.isNotNullOrBlank @@ -45,7 +44,7 @@ import kotlin.time.Duration.Companion.minutes fun DiscoverMovieDetailsHeader( preferences: UserPreferences, movie: MovieDetails, - rating: SourceRatings?, + rating: DiscoverRating?, bringIntoViewRequester: BringIntoViewRequester, overviewOnClick: () -> Unit, showLogo: Boolean, @@ -80,7 +79,7 @@ fun DiscoverMovieDetailsHeader( ) { val resources = LocalResources.current val details = - remember(movie, resources) { + remember(movie, rating, resources) { buildList { movie.releaseDate?.let(::add) movie.runtime @@ -106,11 +105,15 @@ fun DiscoverMovieDetailsHeader( ?.takeIf { it.isNotNullOrBlank() } ?.let(::add) }.let { - QuickDetailsData(listToDotString(it)) + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) } } - QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) 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 407c6935..17280298 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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,11 +19,9 @@ 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 @@ -62,7 +60,6 @@ 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() { @@ -111,11 +108,11 @@ class DiscoverMovieViewModel viewModelScope.launchIO { val rating = - ratingsService.get( - SeerrItemType.MOVIE, - item.id, - TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()), - ) + getDiscoverRating(item.id) { + DiscoverRating( + seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id), + ) + } _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -282,7 +279,7 @@ fun canUserCancelRequest( data class DiscoverMovieState( val movie: DataLoadingState = DataLoadingState.Pending, - val rating: SourceRatings? = null, + val rating: DiscoverRating? = 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 860af84e..afafbd2f 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,8 +34,7 @@ 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.QuickDetailsData -import com.github.damontecres.wholphin.data.model.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.SeerrAvailability import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.canRequestTv @@ -55,7 +54,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.QuickDetails +import com.github.damontecres.wholphin.ui.components.QuickDetailsText import com.github.damontecres.wholphin.ui.components.TitleOrLogo import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo @@ -215,7 +214,7 @@ fun DiscoverSeriesDetailsContent( userConfig: SeerrUserConfig?, series: TvDetails, availability: SeerrAvailability, - rating: SourceRatings?, + rating: DiscoverRating?, canCancel: Boolean, seasons: List, similar: List, @@ -424,7 +423,7 @@ fun DiscoverSeriesDetailsContent( @Composable fun DiscoverSeriesDetailsHeader( series: TvDetails, - rating: SourceRatings?, + rating: DiscoverRating?, overviewOnClick: () -> Unit, showLogo: Boolean, modifier: Modifier = Modifier, @@ -455,7 +454,7 @@ fun DiscoverSeriesDetailsHeader( ) { val resources = LocalResources.current val details = - remember(series, resources) { + remember(series, rating, resources) { buildList { series.firstAirDate?.let(::add) series.episodeRunTime @@ -466,11 +465,15 @@ fun DiscoverSeriesDetailsHeader( ?.let { add(resources.formatDuration(it)) } // TODO }.let { - QuickDetailsData(listToDotString(it)) + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) } } - QuickDetails(details, null, Modifier.padding(start = HeaderUtils.startPadding), ratings = rating) + QuickDetailsText(details, Modifier.padding(start = HeaderUtils.startPadding)) 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 c20ab9c9..ab89b16d 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.SourceRatings +import com.github.damontecres.wholphin.data.model.DiscoverRating 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,10 +18,8 @@ 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 @@ -62,7 +60,6 @@ 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() { @@ -116,11 +113,9 @@ class DiscoverSeriesViewModel viewModelScope.launchIO { val rating = - ratingsService.get( - SeerrItemType.TV, - item.id, - TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()), - ) + getDiscoverRating(item.id) { + DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id)) + } _state.update { it.copy(rating = rating) } } if (state.value.similar.isEmpty()) { @@ -454,7 +449,7 @@ class DiscoverSeriesViewModel data class DiscoverSeriesState( val tvSeries: DataLoadingState = DataLoadingState.Pending, - val rating: SourceRatings? = null, + val rating: DiscoverRating? = 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 cc89159f..3e625d60 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,16 +1,39 @@ 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 3c341ff2..e8872180 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, ep.timeRemainingOrRuntime) + QuickDetails(ep.ui.quickDetails, 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 6dbbf965..ee8f74f0 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, + movie.ui.quickDetails, 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 9a8dd033..d0fea506 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,7 +300,6 @@ 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 be7795f4..b63f1cbc 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?.let { + ep?.ui?.quickDetails?.let { QuickDetails( it, - it.timeRemainingOrRuntime, + ep.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 0308468e..f42961ad 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, + series.ui.quickDetails, 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 3bbc3a6b..568cece8 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,6 +890,5 @@ 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 62075882..cd7c6a27 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,12 +73,11 @@ 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, SourceRatings>>(mapOf()) + val rating = MutableStateFlow>(mapOf()) init { viewModelScope.launchIO { @@ -247,23 +246,50 @@ class SeerrDiscoverViewModel } } - private var ratingJob: Job? = null + private val ratingCache = + CacheBuilder + .newBuilder() + .maximumSize(100) + .build() - /** - * Fetch the ratings for the focused item, cancelling any previous request that is still running - */ + // TODO this is not very efficient fun fetchRating( id: Int, type: SeerrItemType, ) { - 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) } + viewModelScope.launchIO { + val cachedResult = ratingCache.getIfPresent(id) + if (cachedResult != null) { + return@launchIO } + 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() + } + } } } @@ -389,7 +415,7 @@ fun SeerrDiscoverPage( modifier = modifier, ) { val details = - remember(focusedItem) { + remember(focusedItem, ratingMap) { (focusedItem as? DiscoverFocusedItem.Item)?.let { focusedItem -> buildList { focusedItem.item @@ -398,18 +424,23 @@ fun SeerrDiscoverPage( ?.toString() ?.let(::add) }.let { - QuickDetailsData(listToDotString(it)) + val rating = focusedItem.id.let { ratingMap[it] } + val str = + listToDotString( + it, + rating?.audienceRating, + rating?.criticRating?.toFloat(), + ) + QuickDetailsData(str) } } } - 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 26cc96d5..bf24ae00 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,7 +52,6 @@ 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 @@ -73,7 +72,6 @@ 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 @@ -573,7 +571,6 @@ fun HomePageHeader( overview = item?.data?.overview, overviewTwoLines = isEpisode, quickDetails = item?.ui?.quickDetails, - ratings = rememberSourceRatings(item), timeRemaining = item?.timeRemainingOrRuntime, endsAt = item?.data?.endDate, showLogo = showLogo, @@ -589,7 +586,6 @@ fun HomePageHeader( overview: String?, overviewTwoLines: Boolean, quickDetails: QuickDetailsData?, - ratings: SourceRatings?, timeRemaining: Duration?, endsAt: DateTime?, showLogo: Boolean, @@ -615,7 +611,7 @@ fun HomePageHeader( if (subtitle != null) { EpisodeName(subtitle) } - QuickDetails(quickDetails, timeRemaining, endsAt = endsAt, ratings = ratings) + QuickDetails(quickDetails, timeRemaining, endsAt = endsAt) 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 c26cb891..100b6caf 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,7 +771,6 @@ 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 24bd8ee9..e51ccfbb 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,7 +717,6 @@ 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 0e388213..e30484a4 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,9 +298,6 @@ 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 43e213ea..daf6fa07 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,7 +70,6 @@ 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 deleted file mode 100644 index a0814180..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/preferences/WolfskinAbout.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 4d3ff8d3..ddc171c9 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,7 +16,6 @@ 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 @@ -89,24 +88,19 @@ fun UserList( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center, ) { - 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() - } + val focusRequester = remember { FocusRequester() } + val firstFocusRequester = remember { FocusRequester() } + if (users.isNotEmpty()) { + LaunchedEffect(Unit) { focusRequester.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(initialFocusRequester), + .focusRestorer(firstFocusRequester) + .focusRequester(focusRequester), ) { itemsIndexed(users) { index, user -> UserIconCard( @@ -114,7 +108,7 @@ fun UserList( isCurrentUser = user.user.id == currentUser?.id, onClick = { onSwitchUser.invoke(user.user) }, onLongClick = { showDeleteDialog = user }, - modifier = if (index == initialIndex) Modifier.focusRequester(initialFocusRequester) else Modifier, + modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) 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 5faccc05..4a65b3d5 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,7 +30,6 @@ 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 @@ -56,13 +55,13 @@ fun AddSeerrServerApiKey( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } + var url by remember { mutableStateOf(initialUrl) } var apiKey by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } val passwordFocusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { - if (url.isBlank()) { + if (initialUrl.isBlank()) { focusRequester.tryRequestFocus() } else { passwordFocusRequester.tryRequestFocus() @@ -175,7 +174,7 @@ fun AddSeerrServerUsername( .padding(16.dp) .wrapContentSize(), ) { - var url by remember { mutableStateOf(seerrUrlPrefill(initialUrl)) } + var url by remember { mutableStateOf(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 5be93b83..cc68fe04 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,6 +225,10 @@ 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) }, @@ -233,10 +237,6 @@ 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 8cce37e5..8ae95b25 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,10 +13,9 @@ 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.WOLFSKIN } + compositionLocalOf { AppThemeColors.PURPLE } fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = when (appThemeColors) { @@ -28,14 +27,13 @@ fun getThemeColors(appThemeColors: AppThemeColors): ThemeColors = AppThemeColors.BOLD_BLUE -> BoldBlueThemeColors AppThemeColors.RED -> RedThemeColors AppThemeColors.BROWN -> BrownThemeColors - AppThemeColors.WOLFSKIN -> WolfskinThemeColors - AppThemeColors.UNRECOGNIZED -> WolfskinThemeColors + AppThemeColors.UNRECOGNIZED -> PurpleThemeColors } @Composable fun WholphinTheme( darkTheme: Boolean = true, - appThemeColors: AppThemeColors = AppThemeColors.WOLFSKIN, + appThemeColors: AppThemeColors = AppThemeColors.PURPLE, 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 deleted file mode 100644 index ee0a77b5..00000000 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/theme/colors/WolfskinThemeColors.kt +++ /dev/null @@ -1,59 +0,0 @@ -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 2f98fccc..a82201a9 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(GetArtistsRequest::class) - subclass(GetAlbumArtistsRequest::class) + subclass(GetArtistsHandler::class) + subclass(GetAlbumArtistsHandler::class) } } diff --git a/app/src/main/proto/WholphinDataStore.proto b/app/src/main/proto/WholphinDataStore.proto index 55553166..d5bf5701 100644 --- a/app/src/main/proto/WholphinDataStore.proto +++ b/app/src/main/proto/WholphinDataStore.proto @@ -128,7 +128,6 @@ 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 deleted file mode 100644 index 6725d42f..00000000 --- a/app/src/main/res/drawable/ic_rating_imdb.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_rating_tmdb.xml b/app/src/main/res/drawable/ic_rating_tmdb.xml deleted file mode 100644 index 31c0be7d..00000000 --- a/app/src/main/res/drawable/ic_rating_tmdb.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_rt_spilled.xml b/app/src/main/res/drawable/ic_rt_spilled.xml deleted file mode 100644 index fea81f97..00000000 --- a/app/src/main/res/drawable/ic_rt_spilled.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_rt_upright.xml b/app/src/main/res/drawable/ic_rt_upright.xml deleted file mode 100644 index e7166b39..00000000 --- a/app/src/main/res/drawable/ic_rt_upright.xml +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - 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 ec3f55e3..4c5f707b 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -1,4 +1,9 @@ - + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 4ac23262..5b7669dc 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 de1628cc..8e05a03a 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 df5faba9..e6a5972a 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 415cf194..f44d1093 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 d9df942a..9e9214e8 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 dea8ff64..3766935e 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 ee42b91e..4fa742db 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 ee42b91e..52b72c47 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 924b40eb..ce75a196 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 e218f8bd..22298ad1 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 40674eff..3f7c7e29 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 6f5be441..e5cef34d 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 8c1935c7..fafcb4f1 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 48a4e286..5c1a8e36 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 1cf49fb6..90341396 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 afc41a8b..dbe5821e 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 122a33e0..2ef5c7d0 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 d71123be..7de6e9ac 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 Wolfskin - Wolfskin kompaktní + Výchozí předvolba Wholphin + Wholphin 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 a6efeb91..870d8c85 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 - Wolfskin-standard - Wolfskin kompakt + Wholphin-standard + Wholphin kompakt Seriens miniaturebilleder Episode-miniaturebilleder Laveste @@ -637,7 +637,7 @@ Vælg alle Adskil typer Foretræk logoer som titler - Wolfskin er opdateret til %s + Wholphin 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 7170a274..c23c136a 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 - Wolfskin Standard - Wolfskin Kompakt + Wholphin Standard + Wholphin Kompakt Niedrigste Niedrig Mittel @@ -580,7 +580,7 @@ Fortfahren Verschiedene Arten Bevorzugt Logo statt Titel anzeigen - Wolfskin aktualisiert auf %s + Wholphin 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 714ae611..c256d419 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 - Wolfskin se ha actualizado a la versión %s + Wholphin 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 2008bf2c..075fd109 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 - Wolfskini vaikeseadistus - Wolfskini kompaktne vaade + Wholphini vaikeseadistus + Wholphini kompaktne vaade Sarja pisipildid Osa pisipildid Vaikseim @@ -580,7 +580,7 @@ Jätka Eraldi tüübid Pealkirjade puhul eelista logode kuvamist - Wolfskin on uuendatud versioonini %s + Wholphin 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,5 +758,4 @@ 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 85603d28..a7991c1a 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 - Wolfskin Par défaut - Wolfskin Compact + Wholphin Par défaut + Wholphin 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 - Wolfskin a été mis à jour vers la version %s + Wholphin a été mis à jour vers la version %s Voir plus Parcourir les séries Parcourir les films @@ -777,9 +777,4 @@ 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 1eb5b58b..b056a7be 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 - Wolfskin zadani - Wolfskin zbijeni + Wholphin zadani + Wholphin zbijeni Slike sličica serije Slike sličica epizoda Najniža @@ -653,7 +653,7 @@ Odaberi sve Odvojene vrste Preferiraj prikaz logotipa za naslove - Wolfskin ažuriran na %s + Wholphin 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 623ef596..43f0683a 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 - Wolfskin Bawaan - Wolfskin Ringkas + Wholphin Bawaan + Wholphin Ringkas Gambar Thumbnail Seri Gambar Thumbnail Episode Terendah @@ -561,7 +561,7 @@ Pisahkan jenis Buka artis Buka album - Wolfskin diperbarui ke %s + Wholphin 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 be2f53e3..ce58ab95 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 - Wolfskin predefinito - Wolfskin Compatto + Wholphin predefinito + Wholphin Compatto Immagini miniatura serie Immagini miniatura episodio Minimo @@ -599,7 +599,7 @@ Continua Tipi separati Preferisci mostrare i loghi per i titoli - Wolfskin aggiornato a %s + Wholphin 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 d05db022..b0b8b34a 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -517,8 +517,8 @@ מתיחה לתצוגה מתיחה לרוחב מתיחה לגובה - ברירת המחדל של Wolfskin - Wolfskin קומפקטי + ברירת המחדל של Wholphin + Wholphin קומפקטי תמונות־ממוזערות של סדרה תמונות־ממוזערות של פרק כחול עז @@ -594,7 +594,7 @@ בחר הכול סוגים נפרדים העדף להציג לוגו על פני כותרת - Wolfskin עודכן ל־%s + Wholphin עודכן ל־%s מחק מהמשך צפייה מחק סדרה מ־\"הבא בתור\" הצג סדרות שנמחקו מ־\"הבא בתור\" diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 045e125f..55344e51 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 c8066cec..9e26efcc 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 - Wolfskin Standard - Wolfskin Kompakt + Wholphin Standard + Wholphin Kompakt Serieminiatyrbilder Episodeminiatyrbilder Lavest @@ -573,7 +573,7 @@ Velg alle Seperate typer Foretrekk å vise logoer for titler - Wolfskin oppdatert til %s + Wholphin 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 1e4891ba..2d6bd0c4 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 - Wolfskin standaard - Wolfskin compact + Wholphin standaard + Wholphin compact Laag Middel Hoog @@ -603,7 +603,7 @@ Experimentele instellingen inschakelen? Experimentele instellingen - Wolfskin is geüpdatet naar %s + Wholphin 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 eb7a6fe7..55e08a8d 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 - Wolfskin-standard - Wolfskin-kompakt + Wholphin-standard + Wholphin-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 3bb8567e..98c5c2f3 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 Wolfskin - Kompaktowy Wolfskin + Domyślny Wholphin + Kompaktowy Wholphin Miniatury seriali Zdjęcie odcinków Najniższa @@ -576,7 +576,7 @@ Wybierz wszystkie Oddzielne typy Wyświetlanie logo zamiast tytułu - Wolfskin zaktualizowany do %s + Wholphin zaktualizowany do %s Usuń z Kontynuuj oglądanie Usuń serial z Następnie Zobacz serię usuniętą z Następnie @@ -800,9 +800,4 @@ 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 af1e0d9f..e42abda3 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 Wolfskin - Wolfskin Compacto + Padrão do Wholphin + Wholphin 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 - Wolfskin atualizado para %s + Wholphin 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 f65e53af..278e4ce4 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 Wolfskin - Wolfskin Compacto + Padrão do Wholphin + Wholphin 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 - Wolfskin foi atualizado para %s + Wholphin 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 ca59ccb6..d980fd9d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -605,8 +605,8 @@ Растянуть По ширине По высоте - Wolfskin по умолчанию - Wolfskin компактный + Wholphin по умолчанию + Wholphin компактный Миниатюры сериалов Миниатюры эпизодов Самая низкая @@ -683,7 +683,7 @@ Продолжить Разделять по типам Показывать логотипы вместо текстовых названий - Wolfskin обновлён до версии %s + Wholphin обновлён до версии %s Убрать из «Продолжить просмотр» Убрать сериал из «Далее» Показать сериалы, убранные из «Далее» diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 50d2edd7..23cd8aeb 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 - Wolfskin predvolené - Wolfskin kompaktné + Wholphin predvolené + Wholphin 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 - Wolfskin bol aktualizovaný na verziu %s + Wholphin 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 5711dac1..46ec0b68 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 - Wolfskin posodobljen na %s + Wholphin 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 002d5ece..a2e7df01 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 - Wolfskin Kompakt - Wolfskin Standard + Wholphin Kompakt + Wholphin 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 - Wolfskin har uppdaterats till %s + Wholphin 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 2ccccbae..c544add8 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -465,8 +465,8 @@ ยืดเต็มจอ ยืดตามความกว้าง ยืดตามความสูง - Wolfskin ค่าเริ่มต้น - Wolfskin คอมแพค + Wholphin ค่าเริ่มต้น + Wholphin คอมแพค ภาพขนาดย่อของซีรี่ส์ ภาพขนาดย่อของตอน เบาสุด diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 70804da5..9405ba27 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 - Wolfskin Varsayılan - Wolfskin Kompakt + Wholphin Varsayılan + Wholphin 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 - Wolfskin %s sürümüne güncellendi + Wholphin %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 0c0c9727..6b518809 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -567,7 +567,7 @@ Заповнити Заповнити по ширині Заповнити по висоті - Wolfskin за замовчуванням + Wholphin за замовчуванням Мініатюри серіалів Мініатюри серій Найнижча @@ -655,7 +655,7 @@ Вибрати все Розділяти типи Надавати перевагу логотипам для назв - Wolfskin оновлено до %s + Wholphin оновлено до %s Видалити з «Продовжити перегляд» Видалити серіал з «Далі» Переглянути серіали, видалені з «Далі» @@ -686,5 +686,5 @@ Томати Сортування Розділити на окремі рядки - Wolfskin компактний + Wholphin компактний diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 07b57d8e..e36dc507 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 @@ 填充 填充宽度 填充高度 - Wolfskin 默认 - Wolfskin 紧凑 + Wholphin 默认 + Wholphin 紧凑 剧集缩略图 分集缩略图 紫色 @@ -561,7 +561,7 @@ 继续 分离类型 优先显示标题图标 - Wolfskin 已更新至 %s + Wholphin 已更新至 %s 查看更多 发现电视节目 发现电影 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c82ff0d8..09133145 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 - Wolfskin 預設樣式 - Wolfskin 緊湊樣式 + Wholphin 預設樣式 + Wholphin 緊湊樣式 劇集縮圖樣式 單集縮圖樣式 海報 (2:3) @@ -561,7 +561,7 @@ 即時混播 依類型分開顯示 優先顯示圖像標題 - Wolfskin 已更新至 %s + Wholphin 已更新至 %s 查看更多 探索電視劇 探索電影 diff --git a/app/src/main/res/values/ic_banner_background.xml b/app/src/main/res/values/ic_banner_background.xml new file mode 100644 index 00000000..e0e3446a --- /dev/null +++ b/app/src/main/res/values/ic_banner_background.xml @@ -0,0 +1,4 @@ + + + #000B25 + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index 933396c0..ffea1b18 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 @@ - #07070a + #101010 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3783bfdf..cf49b425 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - Wolfskin - Wolfskin + Wholphin + Wholphin About Active Recordings @@ -165,7 +165,6 @@ S%1$02dE%2$02d S%1$02dE%2$02d-E%3$02d E%1$d - Ep Season %1$d TV Shows Interface @@ -604,8 +603,8 @@ Fill Fill width Fill height - Wolfskin Default - Wolfskin Compact + Wholphin Default + Wholphin Compact Series Thumb images Episode Thumbnail images @@ -637,7 +636,6 @@ @string/black @string/red @string/brown - @string/wolfskin_theme Ignore @@ -831,7 +829,7 @@ Select all Separate types Prefer showing logos for titles - Wolfskin updated to %s + Wholphin 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 deleted file mode 100644 index 2aca4d36..00000000 --- a/app/src/main/res/values/wolfskin.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - 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 dc493638..354f3298 100644 --- a/app/src/main/seerr/seerr-api.yml +++ b/app/src/main/seerr/seerr-api.yml @@ -1097,61 +1097,6 @@ 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"] @@ -6583,13 +6528,13 @@ paths: example: 85 criticsRating: type: string - example: 'Certified Fresh' + enum: ['Rotten', 'Fresh', 'Certified Fresh'] audienceScore: type: integer example: 65 audienceRating: type: string - example: 'Upright' + enum: ['Spilled', 'Upright'] /movie/{movieId}/ratingscombined: get: summary: Get RT and IMDB movie ratings combined @@ -6628,13 +6573,13 @@ paths: example: 85 criticsRating: type: string - example: 'Certified Fresh' + enum: ['Rotten', 'Fresh', 'Certified Fresh'] audienceScore: type: integer example: 65 audienceRating: type: string - example: 'Upright' + enum: ['Spilled', 'Upright'] imdb: type: object properties: @@ -6645,12 +6590,8 @@ paths: type: string example: 'https://www.imdb.com/title/tt0480249' criticsScore: - type: number - example: 8.8 - criticsScoreCount: type: integer - format: int64 - example: 2500000 + example: 6.5 /tv/{tvId}: get: summary: Get TV details @@ -6706,7 +6647,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SeasonDetails' + $ref: '#/components/schemas/Season' /tv/{tvId}/recommendations: get: summary: Get recommended TV series @@ -6832,13 +6773,7 @@ paths: example: 85 criticsRating: type: string - example: 'Certified Fresh' - audienceScore: - type: integer - example: 65 - audienceRating: - type: string - example: 'Upright' + enum: ['Rotten', 'Fresh'] /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 deleted file mode 100644 index 14d59cf2..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/WolfskinDefaultsTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index e5c12a56..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/data/model/SourceRatingsTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -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 deleted file mode 100644 index adb5ca33..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsItemsTest.kt +++ /dev/null @@ -1,426 +0,0 @@ -@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 deleted file mode 100644 index c6b008ea..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/services/SeerrRatingsServiceTest.kt +++ /dev/null @@ -1,266 +0,0 @@ -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 2e84e2ca..4cae61ba 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,16 +1,11 @@ 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 @@ -24,151 +19,71 @@ class TestUpdateChecker { lateinit var releaseV108Json: JsonObject val assetsV108Json: JsonArray by lazy { releaseV108Json["assets"]!!.jsonArray } - 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 { + private fun read(filename: String): JsonObject { val resource = javaClass.classLoader?.getResource(filename) Assert.assertNotNull(resource) val fileContents = Paths.get(resource!!.toURI()).readText() - return Json.parseToJsonElement(fileContents) + return Json.parseToJsonElement(fileContents).jsonObject } - 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 does not choose upstream Wholphin assets`() { + fun `Release chooses release`() { val url = getDownloadUrl(assetsJson, false, listOf()) - Assert.assertNull(url) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) } @Test - fun `Choose abi does not choose upstream Wholphin assets`() { + fun `Choose abi`() { val url = getDownloadUrl(assetsJson, false, listOf("arm64-v8a")) - Assert.assertNull(url) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release-arm64-v8a.apk", url) } @Test - fun `Choose unknown abi does not choose upstream Wholphin assets`() { + fun `Choose unknown abi`() { val url = getDownloadUrl(assetsJson, false, listOf("unknown")) - Assert.assertNull(url) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-release.apk", url) } @Test - fun `Debug does not choose upstream Wholphin assets`() { + fun `Debug chooses debug`() { val url = getDownloadUrl(assetsJson, true, listOf()) - Assert.assertNull(url) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug.apk", url) } @Test - fun `Choose debug abi does not choose upstream Wholphin assets`() { + fun `Choose debug abi`() { val url = getDownloadUrl(assetsJson, true, listOf("arm64-v8a")) - Assert.assertNull(url) + Assert.assertEquals("https://github.com/damontecres/Wholphin/releases/download/develop/Wholphin-debug-arm64-v8a.apk", url) } @Test - fun `Test regular release does not choose upstream Wholphin assets`() { + fun `Test regular release`() { releaseV108Json = read("release_v108.json") - 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("arm64-v8a")).let { url -> + Assert.assertEquals( + "https://github.com/damontecres/Wholphin/releases/download/v1.0.8/Wholphin-arm64-v8a.apk", + 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)) + 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 `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 deleted file mode 100644 index 3ef4ad20..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/WolfskinThemeColorsTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 4fb3dc5c..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/components/SourceRatingsRowTest.kt +++ /dev/null @@ -1,281 +0,0 @@ -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 87e5670a..7189adb1 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,49 +292,6 @@ 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 deleted file mode 100644 index a6a23244..00000000 --- a/app/src/test/java/com/github/damontecres/wholphin/ui/setup/UserListFocusTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -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 deleted file mode 100644 index f99734b9..00000000 --- a/app/src/test/resources/forgejo_release_latest.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "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 deleted file mode 100644 index c2155081..00000000 --- a/app/src/test/resources/forgejo_release_suffixed.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "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 deleted file mode 100644 index ab84e411..00000000 --- a/app/src/test/resources/forgejo_releases.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - { - "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 deleted file mode 100644 index ce7d039f..00000000 --- a/docker/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index 394aa9cd..00000000 --- a/docker/README.md +++ /dev/null @@ -1,136 +0,0 @@ -# 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 deleted file mode 100755 index b5624cbf..00000000 --- a/docker/gradle.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/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 deleted file mode 100644 index 7e15afe4..00000000 --- a/docker/release-signing.properties +++ /dev/null @@ -1 +0,0 @@ -release.signing.config=wolfskin diff --git a/docker/release-tag.sh b/docker/release-tag.sh deleted file mode 100755 index 14b2637c..00000000 --- a/docker/release-tag.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 deleted file mode 100644 index b07a4770..00000000 --- a/docker/signing.init.gradle +++ /dev/null @@ -1,17 +0,0 @@ -// 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 - } - } -}